Sparround

async*, yield and transformations

Generator functions produce a sequence on demand instead of building it by hand. Dart has two kinds:

  • sync* → returns an Iterable<T>. Values are computed synchronously and lazily: the body does not run until iterator.moveNext() is called.
  • async* → returns a Stream<T>. Each yield is an asynchronous data event, and the body may await.

In both:

  • yield x emits one value and suspends the function at that point.
  • yield* other delegates to another generator: all of its values are flattened into this sequence. It is equivalent to for (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.

OperatorWhat it doesWatch out
`map(f)`Transforms each event synchronouslyIf `f` is async you get a stream of `Future`s — use `asyncMap`
`where(test)`Drops events failing the testFewer 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** duplicatesIt 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 manySynchronous; 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 with try/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 just add what 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