Result Representations That Did Not Work
Problem Statement
After the isolate-transfer measurements, resqlite had a clearer target but not yet a design. This was the stage where several plausible ideas had to be made concrete enough to fail.
The result shape needed to satisfy three constraints at once:
- Keep the transferred object graph small.
- Keep heavy decode and materialization work off the main isolate.
- Preserve a normal row API for callers.
Several plausible designs met one or two of those constraints, but not all three.
Background
The tempting direction was "make the transfer smaller." That was directionally correct, but incomplete. A design that transfers fewer objects can still lose if it makes the main isolate decode strings, build values, or reconstruct rows during normal use. The core project constraint was not "fewest bytes over the port"; it was "least disruptive database API for Flutter rendering."
The rejected experiments were useful because they clarified which costs mattered and which costs merely moved around. This became a pattern for the project: rejected work still counted if it narrowed the design space and left behind numbers future experiments could trust.
Two Dart runtime details are useful context here. TransferableTypedData is designed for efficiently moving byte sequences between isolates, but creating it is still proportional to the number of bytes. SendPort.send can move ordinary Dart objects between isolates, but mutable object graphs may be copied and the cost is tied to the graph being sent. These APIs are powerful, but neither removes the need to choose a representation that matches where work should happen.
Hypotheses
Three hypotheses were tested:
- A Dart binary codec plus
TransferableTypedDatamight beat object transfer while keeping workers alive. - String interning might reduce the graph that
Isolate.exit()has to validate. - Byte-backed lazy maps might transfer one buffer and decode only what the UI reads.
What We Tried
Experiment 005 encoded rows into bytes on the worker, transferred the bytes, and decoded maps afterward. It tested the most obvious "make it bytes" answer.
Experiment 006 deduplicated repeated strings so the graph would contain fewer distinct string objects. It tested whether the current map shape could be kept by shaving off repeated values.
Experiment 008b packed rows into a compact byte buffer and exposed each row as a lazy map that decoded values on access. It tested whether row materialization could be delayed instead of eliminated.
Results
The Dart binary codec was consistently slower than the VM serializer:
| Rows | Object transport | Binary codec | Result |
|---|---|---|---|
| 100 | 29 us | 240 us | object 8x faster |
| 1,000 | 166 us | 851 us | object 5x faster |
| 5,000 | 724 us | 5,088 us | object 7x faster |
| 10,000 | 1,402 us | 8,304 us | object 6x faster |
String interning looked promising in an isolated transfer benchmark, but failed in the full query path:
| Implementation (5,000 rows) | Wall | vs baseline |
|---|---|---|
| Without value interning | 3.22 ms | - |
| With value interning | 5.30 ms | 64% slower |
The byte-backed result set showed the core trade-off:
| Path (20,000 rows) | Wall | Main |
|---|---|---|
Direct maps + Isolate.exit |
27.45 ms | 2.12 ms |
| Byte-backed full iteration | 11.92 ms | 7.29 ms |
| Byte-backed first 20 rows | 4.50 ms | 0.01 ms |
For partial access, byte-backed rows were excellent. For full access, they moved UTF-8 decode and value materialization onto the main isolate, which violated the original constraint.
Outcome
The rejected designs narrowed the acceptable shape. The solution could be lazy only if the deferred work was cheap. It could use bytes only if callers did not have to decode them on the UI isolate. It could reduce object count only if it did not add data-shape assumptions such as low-cardinality strings.
That led directly to the flat-list ResultSet: materialize values on the worker, transfer a compact graph, and defer only lightweight row facade creation. The next accepted design was less exotic than the failed byte-backed versions, but it matched the product constraint better.
Related Experiments
- Experiment 005: Dart binary codec with TransferableTypedData
- Experiment 006: String interning for Isolate.exit
- Experiment 008b: Byte-backed lazy maps