async*, yield and transformations
Generator functions produce a sequence on demand instead of building it by hand. Dart has two kinds:
sync*→ returns anIterable<T>. Values are computed synchronously and lazily: the body does not run untiliterator.moveNext()is called.async*→ returns aStream<T>. Eachyieldis an asynchronousdataevent, and the body mayawait.
In both:
yield xemits one value and suspends the function at that point.yield* otherdelegates to another generator: all of its values are flattened into this sequence. It is equivalent tofor (final x in other) yield x;but more efficient and more readable.
The important property of async*: it does not run without a listener. If the subscription is paused, the generator body stops at the next yield; if it is cancelled, the body stops entirely. That is the key difference from a StreamController, which has no such automatic backpressure.
| Operator | What it does | Watch out |
|---|---|---|
| `map(f)` | Transforms each event synchronously | If `f` is async you get a stream of `Future`s — use `asyncMap` |
| `where(test)` | Drops events failing the test | Fewer events, but `done` still arrives |
| `take(n)` / `skip(n)` | Takes / skips the first `n` events | `take` **cancels upstream** once the count is reached |
| `distinct()` | Drops **consecutive** duplicates | It remembers no history — `a, b, a` passes all three |
| `asyncMap(f)` | Awaits `f` for each event and **preserves order** | Not parallel — a slow `f` throttles the whole stream |
| `expand(f)` | Expands one event into many | Synchronous; if you need async, write an `async*` |
| `transform(t)` | Applies a fully controlled `StreamTransformer` | For time-based logic such as debounce, throttle, buffer |
Debounce and throttle — the intuition. Both reduce a flood of events, but their logic differs:
- Debounce waits until the stream goes quiet, then emits the last value. That is what a search field wants: one request 300 ms after the user stops typing.
- Throttle emits at most one value per interval. That is what scroll or a sensor wants: 10 events per second instead of 60.
Dart's core library ships no debounce; you either write one over a Timer with StreamTransformer.fromHandlers, or take rxdart's debounceTime/throttleTime.
Backpressure — what happens when the producer outruns the consumer? Dart's answer: on a single-subscription stream, pause() slows the source (an async* generator waits at its yield, a file read stops) — that is genuine backpressure. But a StreamController slows nothing: add calls pile into a buffer and memory grows. A broadcast stream does not pause its source either. So for a fast producer with a slow consumer you need either an async* generator (natural backpressure) or explicit sample/throttle/buffer logic.
*`async or StreamController`?** A practical rule:
- If the values are produced by your code (a loop, all pages of a paginated API, a retry chain) →
async*. Shorter, handles cancellation itself, provides backpressure, and lets you write cleanup withtry/finally. - If the events arrive from outside (a callback API, a platform channel, a manual event bus) →
StreamController. There you do not control the timing, you justaddwhat arrives.
One important nuance: in an async* function the finally block also runs when the subscription is cancelled — the reliable way to release a resource:
Stream<String> readLines(File f) async* { final h = await f.open(); try { ... yield line; } finally { await h.close(); } }
With a StreamController you have to write the same cleanup by hand in the onCancel callback.
Interview tip. Two questions come up often.
"What is the difference between `map` and `asyncMap`?" — map transforms synchronously; if your function is async the result is a Stream<Future<T>> that nobody awaits. asyncMap awaits each result. Add the critical detail: `asyncMap` is not parallel and preserves order — one slow call throttles the whole stream. For parallelism you batch events and Future.wait, or use rxdart's flatMap family.
"Does `distinct()` remove duplicates?" — the trap is here: it only removes consecutive duplicates. In a, b, a all three pass, because distinct keeps no history. A candidate who answers this in one sentence stands out immediately.
The most common mistake is treating async* as merely shorthand for a StreamController. The difference is backpressure — an async* suspends its work while the listener is busy, whereas a controller keeps piling events into a buffer.
📚 Sources and documentation
- Creating streamsofficialdart.dev