All tags across posts, sorted

Toss-up

Requirement

Each blog post carries a list of tags. Build the site's tag index: flatten every post's tags into one sequence, drop duplicates, sort alphabetically, and print them as a single comma-separated line. The data is in the code below; both versions must print the line shown under Expected output.

Expected output
async, concurrency, dart, fp, iterables, recipes, streams

Side by side

Native Dart

FxDart

Why they differ

On the page, barely at all. expand is Dart's flatMap, toSet() deduplicates, and a cascade ..sort() finishes the job — that chain is honest, idiomatic Dart and there is nothing wrong with it. FxDart spells the same three steps as named chain links (flatMap → uniq → sort), which reads slightly more like the requirement and keeps the order-preserving uniq explicit rather than a side effect of choosing a Set. As code, this one is a tie.

The clock is not a tie. The Benchmark bars below have FxDart at 1.47× the speed of the native chain on a million posts — 73.5 ms against 108.0 ms — and the ratio holds all the way down (1.36× at N=10,000, 1.29× at N=100; those two still carry a same speed badge only because the absolute gap there is under the site's 0.6 ms perception floor). Both sides do identical work: three million tag strings pulled through a flattener, hashed into a set that keeps 500 distinct values, then a 500-element sort. Nothing about the algorithm differs.

The whole gap lives in one field of dart:core's ExpandIterator. It seeds its inner-iterator slot with a const EmptyIterator<Never>() sentinel so it can defer the first callback, which means the hot line — _currentExpansion!.moveNext(), run once per emitted tag — sees two receiver classes over the loop's life. That is enough to keep AOT from inlining the inner List iterator, so all three million inner advances become indirect calls. FxDart's flatMap holds a plain Iterator<B>? that only ever contains the real inner iterator and uses null for "nothing open yet", so the same call site stays monomorphic and inlines.

That is not a guess. Swapping only the flattener — FxDart's flatMap feeding native's own toSet().toList()..sort() — already lands at 78 ms; swapping only the other end, core's expand into uniq and sort, stays at 108 ms. Hand-copying ExpandIterator into the benchmark and changing nothing but that sentinel (empty-iterator seed → null) moves it from 105 ms to 77 ms on its own; the other difference in shape, core's nullable _current read through a cast, costs nothing measurable. Each variant was AOT-compiled as its own binary, because putting them in one program makes every moveNext call site polymorphic and erases the effect being measured.

Two caveats worth keeping. This is an implementation detail of the SDK, not a law: the day ExpandIterator drops that sentinel, expand matches FxDart and this page goes back to a tie in both columns. And a hand-written nested for loop that adds straight into a Set beats both, at 43 ms — dropping the iterator protocol entirely is still the fastest thing you can do here. What the measurement rules out is the assumption that reaching for a named pipeline costs you speed over the idiomatic core chain. Here it buys some.

Benchmark

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

N = 100

Time Tie

Native Dart 60 µs
FxDart 48 µs

Peak memory Tie

Native Dart 15.9 MB
FxDart 16.5 MB

N = 10,000

Time Tie

Native Dart 1.25 ms
FxDart 920 µs

Peak memory Native wins

Native Dart 20.6 MB
FxDart 23.2 MB

N = 1,000,000

Time FxDart wins

Native Dart 106.9 ms
FxDart 77.8 ms

Peak memory Tie

Native Dart 161.7 MB
FxDart 161.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.