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:

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.

Reference Docs