In a Flutter app using Drift, how do you keep SQLite work off the UI isolate, and what changes when a background isolate needs the same database?
answer
- sqlite3 over FFI is synchronous
- NativeDatabase.createInBackground
- driftDatabase(name:) from drift_flutter
- a database object cannot be sent
- shareAcrossIsolates or computeWithDatabase
basics
~10 sOpen the database with NativeDatabase.createInBackground or drift_flutter's driftDatabase, which run SQLite on a drift-managed isolate. A background isolate must connect to that same instance (shareAcrossIsolates, DriftIsolate, computeWithDatabase) rather than open its own copy.
solid answer
~40 sSQLite is a synchronous C library reached through `dart:ffi`, so a plain `NativeDatabase(file)` runs every statement on the calling isolate and can drop frames. `NativeDatabase.createInBackground(file)` instead spawns an isolate that owns the connection and relays queries over ports; drift_flutter's `driftDatabase(name: 'habits')` does the same, storing `habits.sqlite` in the documents directory by default. Callbacks such as `setup` are sent to that isolate, so they cannot rely on main-isolate globals. You cannot send a drift database object to another isolate. For short heavy work use `computeWithDatabase`; for a long-lived background worker, set `DriftNativeOptions(shareAcrossIsolates: true)` or connect to a `DriftIsolate`, so both isolates share one logical database and their stream queries stay synchronised. Two independently opened instances on the same file work but do not synchronise streams.
code
dart · 16 linesimport 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:path_provider/path_provider.dart';
QueryExecutor openHabitsDb() {
return driftDatabase(
name: 'habits',
native: const DriftNativeOptions(
databaseDirectory: getApplicationSupportDirectory,
shareAcrossIsolates: true,
),
);
}
// AppDatabase(openHabitsDb()) on the UI isolate and in a background
// worker both connect to one server isolate, so streams stay in sync.go deeper
Recall that heavy database work should not run on the UI isolate and that drift_flutter's driftDatabase handles this for you.
Explain why FFI calls block despite await, what createInBackground does, and why a drift database object cannot be sent to another isolate.
Design access from a background worker with shareAcrossIsolates, DriftIsolate or computeWithDatabase, and explain stream synchronisation and locking trade-offs.
Decide how much concurrency the persistence layer should expose, weighing read pools and shared servers against simplicity and startup cost.
## Why the UI isolate is the wrong place for SQLite Flutter runs your Dart code, including `build` methods and gesture handlers, on the **UI isolate**. If that isolate blocks, frames are missed. **SQLite**, the engine under Drift on Android, iOS and desktop, is a **synchronous C library** that drift calls through `dart:ffi`. A Dart `await` does not make an FFI call asynchronous: with a plain `NativeDatabase(File(...))`, every query and write executes on whichever isolate called it, and a slow query or a large batch insert stalls the UI. ## Drift's background executors Drift's answer is to move the connection itself to another isolate and talk to it through ports. - **`NativeDatabase.createInBackground(file)`** spawns an isolate that opens the SQLite file and serves statements; your database class on the UI isolate sends requests and awaits responses. Closing the database shuts that isolate down. - **`driftDatabase(name: ...)`** from `drift_flutter` is the Flutter-friendly wrapper used in drift's setup guide. It resolves the file path (by default `getApplicationDocumentsDirectory()`, changeable with `DriftNativeOptions(databaseDirectory: ...)`), points SQLite's temporary directory at the app's temp directory because Android forbids `/tmp`, and opens a background connection. - **`readPool`** on `createInBackground` adds reader isolates for `SELECT` statements. It only helps when write-ahead logging is enabled in `setup`, and most apps do not need it. Callbacks passed to these factories, notably `setup` and `isolateSetup`, are **sent to the background isolate** and run there. They see that isolate's globals, not yours, so they should not capture main-isolate state. | Option | Where SQLite runs | Streams shared with UI? | |---|---|---| | `NativeDatabase(file)` | calling isolate | n/a | | `createInBackground` / `driftDatabase` | drift-managed isolate | yes, one instance | | second independent `NativeDatabase` in a worker | the worker | no | | `shareAcrossIsolates: true` or `DriftIsolate.connect` | one server isolate | yes | ## When another isolate needs the database A habit tracker might run a background task that writes reminder check-ins while the UI shows the streak screen. Three facts shape the design: 1. **A drift database object cannot be sent between isolates.** It holds stream-query state and transaction machinery, so sending it fails with an invalid-object error. 2. **Independent instances on one file work but do not synchronise.** A worker can open its own drift database on the same path, but writes there will not re-run the UI's `watch()` streams, and concurrent transactions can produce "database is locked" errors. 3. **Sharing one logical database fixes both.** With `DriftNativeOptions(shareAcrossIsolates: true)`, `drift_flutter` registers a server isolate in `IsolateNameServer` and every isolate in the same Flutter engine connects to it, so stream updates propagate. The lower-level route is `DriftIsolate.spawn()` or `DriftIsolate.inCurrent()` plus `connect()`, wrapped in `DatabaseConnection.delayed` when construction must be synchronous. For a one-off heavy job, such as importing a year of check-ins, `computeWithDatabase(computation: ..., connect: ...)` spawns a short-lived isolate with a second database instance wired to the main one over ports, so its writes still reach the UI's streams. Your database class needs a constructor that accepts a `QueryExecutor` or `DatabaseConnection` for this to work. ## Diagnosing a database-caused jank A typical symptom is a frame spike each time the streak screen opens or a batch import runs. In the DevTools timeline, the UI isolate shows a long Dart event with drift's statement execution under it rather than a frame build. The usual causes, in order: 1. The database was opened with a plain `NativeDatabase(file)` or a hand-rolled `LazyDatabase` returning one, so SQLite runs on the UI isolate. 2. The query is fine but its result is huge, and mapping thousands of rows into widgets costs more than the SQL. 3. A long migration or seeding step in `beforeOpen` delays the first screen that awaits the database, and blocks the UI isolate outright if the connection is synchronous. Switching the opener to `driftDatabase` or `createInBackground` fixes the first; the other two are query-shape and startup-ordering problems. ## Judgement calls - Default to `driftDatabase` or `createInBackground`; a synchronous `NativeDatabase` is reasonable mainly in tests, via `NativeDatabase.memory()`. - Enable `shareAcrossIsolates` only when a second isolate actually uses the database, since forwarding table updates across isolates adds a small overhead. - `IsolateNameServer` is per engine, so an isolate started by a separate Flutter engine cannot find the shared server. - Drift's docs warn against `LazyDatabase` for multi-client isolate setups because it shares only the executor, not stream synchronisation.
- In Drift, why is computeWithDatabase not a speed-up when the database was opened with a plain synchronous NativeDatabase?It does not open a second SQLite connection; the background instance relays statements back to the main one. If that main connection executes on the UI isolate, the SQL still runs there. Drift's docs therefore recommend `createInBackground` as the base, and `computeWithDatabase` for expensive Dart-side work around the queries.
- What does DriftNativeOptions(shareAcrossIsolates: true) rely on, and where does it stop working?It registers the database server's port in Flutter's `IsolateNameServer` under a name derived from the database name, and other isolates look it up and connect. That registry is per Flutter engine, so isolates belonging to a different engine cannot discover the shared server.
saying these in an interview costs you the question
- Awaiting a drift query already moves the SQLite work off the UI isolate.
- You can send the AppDatabase instance to a worker with Isolate.run.
- Two instances on the same file keep their watch() streams in sync.
- readPool speeds up reads even without write-ahead logging.
- The setup callback can read main-isolate globals safely.