First transaction over budget
Requirement
Scan this week's card feed in arrival order and report the first transaction over the 100 budget — then stop looking. Also print how many transactions were actually examined, to prove the search short-circuited. The data is in the code; both versions must print the lines shown under Expected output.
Expected output
First over budget: T-04 (128) Examined 4 of 8 transactions
Side by side
RxDart
FxDart
Why they differ
Both sides are genuinely lazy here, each in its own dialect.
RxDart's firstWhere resolves its future on the first
match and cancels the subscription — the four
remaining transactions are never delivered. FxDart's
find simply stops pulling — the four
remaining transactions are never demanded. Cancellation and demand
are the two models' words for the same economy, and the
"Examined 4 of 8" line comes out identical on both sides.
The instructive difference is where the count lives. A
stream has a "between": doOnData taps the pipe between
operators, so the rx predicate stays pure while the tap observes
traffic. A pull chain has no between — the moment of demand is the
predicate call itself, so the FxDart side counts inside it. Neither
spelling is better; they are the observation idioms native to push
and pull. Verdict: a tie — one operator each, and both stop at
exactly the right moment.
Why the benchmark gap is so wide
The bars below are not measuring laziness. At the benchmark's scale the first over-budget transaction sits at element 900,001 of a million, and both sides examine exactly 900,001 — the checksums prove it. What the bars measure is the price of one element passing through each model: about 1 ns on the pull side, about 88 ns on the push side.
That is not a flaw in RxDart, and it is not a spelling that a better operator choice fixes. We measured the alternatives on the same dataset — 900,001 elements, identical results:
where().firstinstead offirstWhere: 50 ns (the fastest operator pairing we found)- counting inside the predicate instead of a
doOnDatatap, which removes one transformer layer: 62 ns await forwith abreak, no operators at all: 227 ns — the slowest, not the fastest- a hand-driven synchronous
StreamController, which is no longer idiomatic Rx but is the floor for the push model: 20 ns
Even that floor is 20× the pull chain. The reason is structural. A
Stream is a delivery mechanism: each value is
handed to a subscription, through however many transformer layers the
chain has, with the event-loop discipline that makes a stream safe to
share, pause, cancel and compose across async boundaries. FxDart's
find over a List compiles to an indexed loop
that calls one closure per element and returns — there is no delivery,
no subscription, no scheduling, because nothing here is actually
asynchronous.
Does the trigger's position rig it?
Fair question, and the one thing about this case that is a judgement call. Because the search short-circuits, the dataset decides how much work happens: the benchmark puts the first over-budget transaction at 90% of the way through, so a million-element run examines 900,001. Move it and both sides do proportionally less. If the gap were an artefact of that choice, moving the trigger earlier would close it.
It does not. Measured back to back on an idle machine, five rounds each, with the 90% dataset run twice to show the noise:
| Scale | Trigger | Examined | RxDart | FxDart | Ratio |
|---|---|---|---|---|---|
| 100 | 90% | 91 | 10 µs | 286 ns | 35× |
| 100 | 90% (repeat) | 91 | 11 µs | 280 ns | 39× |
| 100 | 50% | 51 | 7.5 µs | 256 ns | 29× |
| 1,000,000 | 90% | 900,001 | 79.1 ms | 861 µs | 92× |
| 1,000,000 | 90% (repeat) | 900,001 | 78.9 ms | 901 µs | 88× |
| 1,000,000 | 50% | 500,001 | 44.2 ms | 374 µs | 118× |
Halving the work halves both sides — and the ratio widens, from 88× to 118×. Divide the million-element rows by the elements examined and the reason is plain: RxDart costs 87.7, 87.9 and 88.4 ns per element across the three runs, flat regardless of where the trigger sits, while FxDart costs 1.00, 0.96 and 0.75 ns — the shorter scan is slightly cheaper per element still. An earlier trigger, if anything, flatters FxDart. The 100-element rows are dominated by fixed setup rather than per-element cost, which is why their ratios are smaller and noisier; that is also why the headline scale exists.
So 90% is not there to inflate anything — it is there so the headline measures pulling rather than startup, and it is the least FxDart-favourable of the positions we tried. What the position cannot change is the per-element price, and that is the whole of the gap.
So the honest reading of these bars is narrow: when the source is already in memory and the question is synchronous, routing it through a stream is pure overhead. Turn the source into something genuinely asynchronous — a socket, a websocket, a paginated API — and that per-element cost disappears under the I/O it was designed to manage, which is exactly the ground Part 4 covers. The code verdict stays a tie: both spell the same short-circuit in one operator, and both stop at the right moment.
Benchmark
N = 100
Time Tie
Peak memory Tie
N = 1,000,000
Time FxDart wins
Peak memory FxDart wins
Bars are medians of repeated timed iterations in fresh processes per side (small N is batched for timer resolution). Sides within 5% of each other — or within 0.6 ms, a difference no person can perceive — count as a tie; close relative races are re-measured up to 5 runs. In an app, anything under a few milliseconds is invisible to the user regardless of which bar is shorter. Memory is peak process RSS. The Dart VM and the dataset are identical on both sides, so the difference between the two bars is what the pipeline itself holds onto.