Esta página ainda não foi traduzida, por isso é exibida em inglês. Ajude a traduzir

First transaction over budget

Toss-up

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:

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:

ScaleTriggerExamined RxDartFxDartRatio
10090%91 10 µs286 ns35×
10090% (repeat)91 11 µs280 ns39×
10050%51 7.5 µs256 ns29×
1,000,00090%900,001 79.1 ms861 µs92×
1,000,00090% (repeat)900,001 78.9 ms901 µs88×
1,000,00050%500,001 44.2 ms374 µs118×

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

Apple M1 Max, 32 GB RAM · Dart 3.12.2 (AOT-compiled) · 2026-08-18

N = 100

Time Tie

RxDart 11 µs
FxDart 293 ns

Peak memory Tie

RxDart 17.1 MB
FxDart 16.3 MB

N = 1,000,000

Time FxDart wins

RxDart 78.8 ms
FxDart 870 µs

Peak memory FxDart wins

RxDart 122.4 MB
FxDart 111.8 MB

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.