Understanding Dart Isolate Transfer Costs
Problem Statement
After the first measurements, the project had a direction: keep the UI isolate out of SQLite work and result preparation. The next question was whether Dart's isolate transfer machinery already solved enough of that problem.
Isolate.exit() can transfer ownership of an object graph instead of copying it. That sounds ideal for query results: run SQLite on a worker, transfer rows back, and let the caller keep using normal Dart objects.
The problem was that zero-copy transfer is not zero-cost transfer. The Dart VM still validates the graph before ownership changes. For database results represented as thousands of maps, that validation can be a major part of the query.
Background
By this point, the read path had C-backed connection state, statement caching, and fewer SQLite mutex operations. Those changes made SQLite execution less of a confounding variable. That was an important phase shift: once the native side stopped dominating the numbers, the library could see the cost of the Dart boundary itself.
The row representation was still conventional: one map per row. That shape is convenient for callers, but it creates many heap objects: map objects, hash-table internals, entry structures, column-name references, and boxed values.
The relevant Dart APIs have different transfer models. SendPort.send sends asynchronously and may copy the transitive object graph. Isolate.exit terminates the current isolate and can transfer the final message more efficiently because the sender will no longer access it. Flutter's isolate docs summarize the practical distinction: SendPort.send copies mutable messages, while Isolate.exit can pass ownership.
Hypothesis
If Isolate.exit() validation walks every reachable heap object, then reducing the number of structural Dart objects should matter more than reducing FFI calls. A result shape with fewer maps should transfer faster even if the logical data is the same.
What We Tried
The early implementation kept the map representation and measured scaling as result size grew. This was intentionally conservative: before inventing a custom row type, resqlite needed to know whether the normal Dart shape was actually the problem.
A later phase breakdown, recorded in Experiment 008, separated SQLite stepping, Dart object construction, and isolate validation.
Results
The scaling data showed the gap widening with result size:
| Rows | resqlite | sqlite3 |
|---|---|---|
| 5,000 | 4.82 ms | 4.43 ms |
| 10,000 | 12.85 ms | 8.65 ms |
| 20,000 | 26.81 ms | 22.37 ms |
The 20,000-row phase breakdown made the hidden cost explicit:
| Phase (20,000 rows) | Cost | Share |
|---|---|---|
| Isolate spawn | 0.09 ms | 0.4% |
| SQLite step | 2.10 ms | 9.5% |
| Map building | 11.50 ms | 52.0% |
Isolate.exit validation |
8.44 ms | 38.1% |
Almost 40% of the measured time was isolate validation. The database query itself was not the dominant cost.
Outcome
This separated two ideas that are easy to conflate:
Isolate.exit()can avoid copying.Isolate.exit()still validates the object graph.
A single Uint8List crosses cheaply because the graph is structurally small. A List<Map<String, Object?>> crosses without copying, but the VM still has to inspect a very large graph.
The next optimization target was therefore not "make FFI faster." It was: find the smallest Dart object graph that can still present a normal row-map API. That discovery pushed the project into a short but useful run of rejected designs.
Related Experiments
- Experiment 008: Flat value list with lazy ResultSet
- Experiment 012: SendPort vs Isolate.spawn deep dive