Experiment 256: Emit stale-then-correct, or wait and emit only the truth?

Date: 2026-07-28

Status: Accepted

Direction:stream-rerun-dispatch

Benchmark Run: none — focused

benchmark/experiments/stream_initial_emission_ab.dart;

raw table in

benchmark/results/2026-07-28-exp256-initial-emission.md.

Problem

Exp 255 fixed a stream that could go

permanently silent when a write raced its initial query. But it left a design

question the bug had obscured: once you guarantee an emission, which rows

should the first one carry?

write has already superseded them, then correct via the catch-up re-query.

the re-query is guaranteed to emit, and let the corrected result be the

stream's first.

Both fix the silence. A is unconditional by construction, which is why it

shipped first. B is what the stream's consumers arguably want — but it keeps

the "suppress the initial emission" shape that caused the bug, so it needed

evidence rather than preference.

Hypothesis

A's advantage — a faster first emission — is measured against the wrong clock.

What a subscriber cares about is time-to-correct-value: when it can trust

what it is looking at. Since the catch-up re-query runs in both designs, B

should cost little on that clock while removing the stale frame entirely.

Approach

The harness opens a stream over a seeded table and fires eight inserts

without awaiting the first emission, so the writes race the initial query.

Per observation it records time-to-first-emission, time-to-correct-value,

emission count, and how many emissions carried a row count that was already

wrong. Seed sizes of 1k / 20k / 60k widen the race window (the window is the

initial query's duration), plus a no-race control that must be identical

between lanes. 12 samples per shape after warmup.

Results

Medians, Apple M1 Pro:

shapemetricA emit-then-correctB suppress-stale
racing · 20ktime to correct (p50 / p90)8.1 / 10.0 ms9.5 / 15.8 ms
racing · 20kstale frames per stream1.000.00
racing · 60ktime to correct (p50 / p90)18.5 / 22.9 ms18.9 / 21.4 ms
racing · 60kstale frames per stream1.000.00
racing · 1kstale frames per stream1.080.42
no race · 20k (control)time to correct13.3 ms13.3 ms
no race · 20k (control)emissions2.002.00

B costs ~1.4 ms at 20k rows and ~0.4 ms at 60k on time-to-correct-value

far less than a full extra query, because the re-query is already in flight in

both lanes; B simply declines to paint while it waits.

B removes the stale frame completely (1.00 → 0.00 at every raced size). A

renders exactly one wrong frame on every raced stream — the list-jump artifact

a reactive stream exists to prevent.

A's fast first emission measures the wrong thing. Its 3.1 ms first emission

at 20k delivers rows already known to be superseded; the subscriber must

disregard them for another 5 ms.

The control is identical — unraced streams behave the same in both lanes

(13.3 ms, 2 emissions), confirming the change is scoped to the raced path.

At 1k rows the race window is small enough that it often does not land at all,

which is why B still shows 0.42 stale frames there: those are the samples where

the writes completed before the initial query started, so both lanes correctly

emit the pre-write state first.

Decision

Accepted — ship B. It is better on the metric that matters (never showing

known-stale data), costs about a millisecond on time-to-truth, and is identical

when nothing races.

Crucially it is not a return to the exp 255 bug: the poisoned baseline

guarantees the re-query emits, and _requery propagates errors to subscribers,

so the stream always resolves to a value or an error. The bug was suppression

without poisoning.

Would revisit if a workload cared more about first-paint latency than

correctness of first paint — a skeleton-UI pattern that would rather show stale

rows than nothing. That is a public-API question (an opt-in emission policy),

not a default worth changing.