← Back to blog
Async & Concurrency in Dart · Part 6 of 10
July 10, 202610 min read

Generators in Dart: sync*, async*, yield and yield* Explained

DartAsyncFlutter

Generators in Dart

This is Part 6 of the Async & Concurrency in Dart series. In Part 5 you produced streams imperatively by pushing events into a StreamController. Generators are the other half of the story: they let you pull a sequence out of ordinary sequential code — a loop, a recursion — and have Dart turn it into an Iterable or a Stream lazily, computing each value only when it's actually needed.

The magic word is yield: "produce this value now, pause right here, and resume from this exact spot when the consumer asks for the next one."


The vending machine analogy

A normal function that returns a list is like a vending machine that drops the entire shelf at once — it computes every value, stuffs them in a List, and hands you the whole thing. If there are a million values (or infinitely many), too bad — it builds them all up front.

A generator is a vending machine that dispenses one item per button press. It computes a value, hands it over, and freezes until you press again. No wasted work, no giant list in memory, and it can even represent an infinite sequence (you just stop pressing).

Dart has two kinds of generators:

| Generator | Body marker | Returns | Consumed with | | --- | --- | --- | --- | | Synchronous | sync* | Iterable<T> | for-in, .take(), etc. | | Asynchronous | async* | Stream<T> | await for, .listen() |

The * (star) is what turns a normal function into a generator. Inside, yield emits values one at a time.


Synchronous generators: sync*Iterable

A sync* function returns an Iterable<T>. Each yield produces the next element, on demand:

Iterable<int> naturalsTo(int n) sync* {
  int k = 0;
  while (k < n) {
    yield k++; // produce k, then pause until the consumer wants more
  }
}

void main() {
  for (final value in naturalsTo(5)) {
    print(value); // 0 1 2 3 4
  }
}

Laziness is the whole point

Here's the part people miss. The body of a sync* function doesn't run until you start iterating — and it only runs as far as needed:

Iterable<int> noisyNumbers() sync* {
  print('computing 1'); yield 1;
  print('computing 2'); yield 2;
  print('computing 3'); yield 3;
}

void main() {
  final it = noisyNumbers(); // prints NOTHING yet — nothing has run
  print('got the iterable');

  print(it.take(2).toList()); // only computes 1 and 2
}

Output:

got the iterable
computing 1
computing 2
[1, 2]

Notice 'computing 3' never printstake(2) stopped pulling after two values, so the generator paused at the second yield and was never resumed. That laziness lets you do things impossible with a list:

// An INFINITE sequence — perfectly fine because it's lazy.
Iterable<int> naturals() sync* {
  int i = 0;
  while (true) yield i++; // never terminates on its own...
}

void main() {
  print(naturals().take(5).toList()); // ...but take(5) only pulls 5: [0,1,2,3,4]
}

A sync* generator that built a list instead would hang forever. The generator only computes what's consumed.


yield* — delegate to another sequence

yield emits one value. yield* ("yield-star" / yield-each) emits every value of another iterable (or stream), splicing it into your sequence. It's the clean way to write recursive generators:

Iterable<int> countDownFrom(int n) sync* {
  if (n > 0) {
    yield n;                       // emit n
    yield* countDownFrom(n - 1);   // then emit ALL of the recursive sequence
  }
}

void main() {
  print(countDownFrom(4).toList()); // [4, 3, 2, 1]
}

Without yield* you'd have to manually loop over the recursive result and re-yield each element. yield* does that delegation for you — and does it more efficiently (it doesn't re-wrap each level). Think of yield* as "flatten this whole sub-sequence into mine."

// yield* also splices in any iterable, not just recursion:
Iterable<String> menu() sync* {
  yield 'starter';
  yield* ['soup', 'salad'];   // splice these two in
  yield 'dessert';
}
// → starter, soup, salad, dessert

Asynchronous generators: async*Stream

Swap sync* for async* and Iterable for Stream. Now you can await inside the generator between yields — perfect for sequences that unfold over time:

Stream<int> countStream(int max) async* {
  for (int i = 1; i <= max; i++) {
    await Future.delayed(const Duration(seconds: 1)); // wait between events
    yield i; // emit i as a stream event
  }
}

void main() async {
  await for (final n in countStream(3)) {
    print(n); // 1 (after 1s), 2 (after 2s), 3 (after 3s)
  }
}

This is dramatically cleaner than the equivalent StreamController from Part 5: no manual add, no close, no onListen/onCancel bookkeeping. The async* machine handles all of it:

  • The body starts when a listener subscribes (just like onListen).
  • Each yield becomes a data event.
  • When the function ends, the stream is automatically closed (done event).
  • If the listener cancels, the next yield behaves like a return — the generator stops cleanly. (No manual cleanup of the loop needed.)

yield* works here too, splicing one stream into another:

Stream<int> combined() async* {
  yield* countStream(2);  // 1, 2
  yield* countStream(3);  // then 1, 2, 3
}

sync* vs async* vs StreamController: choosing

Do you produce a sequence from your OWN sequential code?
├── Need values NOW, synchronously (no awaiting)?  → sync*  (Iterable)
└── Need to await between values / events over time? → async* (Stream)

Do events come from OUTSIDE (callbacks, sockets, UI, multiple listeners)?
└── → StreamController (Part 5)
  • sync* — pure, synchronous, lazy sequences: ranges, tree/graph traversals, parsing tokens, paginating an in-memory structure. No await allowed inside.
  • async* — sequences that need to wait between elements or react to async work: polling, paged network fetches, debounced values, time-based emissions.
  • StreamController — when the events are pushed from outside your control flow, or you need broadcast/multiple listeners.

⚠️ A sync* generator can't use await — it's synchronous. If you find yourself wanting to await between yields, that's the signal to switch to async*.


A practical example: paginated fetch with async*

A very common real pattern — lazily stream items from a paginated API, page by page, stopping when there are no more. The consumer can quit early and you simply stop fetching:

Stream<Item> fetchAllItems() async* {
  var page = 1;
  while (true) {
    final batch = await api.getPage(page); // await between yields
    if (batch.isEmpty) return;             // ends the stream cleanly
    for (final item in batch) {
      yield item;                          // emit items one by one
    }
    page++;
  }
}

// Consumer pulls only what it needs:
await for (final item in fetchAllItems().take(30)) {
  render(item); // stops fetching more pages once 30 are taken
}

If the consumer takes only 30 items, fetchAllItems stops requesting pages after it has yielded 30 — the cancel-on-take causes the next yield to act as a return. Laziness saving network calls, for free.


Practice Challenges

Challenge 1 — Range generator. Write a sync* function range(start, end) that yields integers from start up to (excluding) end.

Show solution
Iterable<int> range(int start, int end) sync* {
  for (var i = start; i < end; i++) {
    yield i;
  }
}

void main() => print(range(2, 6).toList()); // [2, 3, 4, 5]

Challenge 2 — Prove laziness. Show that an infinite sync* generator works with take.

Show solution
Iterable<int> evens() sync* {
  var n = 0;
  while (true) yield n += 2; // 2, 4, 6, ...
}

void main() => print(evens().take(4).toList()); // [2, 4, 6, 8]

It never hangs because only four values are ever pulled; the generator pauses at the fourth yield.

Challenge 3 — Recursive yield*. Flatten a nested list of ints one level using a generator. (e.g. [[1,2],[3],[4,5]]1 2 3 4 5.)

Show solution
Iterable<int> flatten(List<List<int>> nested) sync* {
  for (final inner in nested) {
    yield* inner; // splice each sub-list in
  }
}

void main() => print(flatten([[1, 2], [3], [4, 5]]).toList()); // [1,2,3,4,5]

yield* inner emits every element of inner without a manual inner loop.

Challenge 4 — async* ticker. Write a stream that yields "tick" every 300ms, five times, then stops.

Show solution
Stream<String> ticks() async* {
  for (var i = 0; i < 5; i++) {
    await Future.delayed(const Duration(milliseconds: 300));
    yield 'tick ${i + 1}';
  }
}

void main() async {
  await for (final t in ticks()) print(t);
}

When the loop ends, the stream auto-closes — no controller.close() needed.

Challenge 5 — Pick the tool. For each, choose sync*, async*, or StreamController: (a) Fibonacci numbers on demand; (b) emitting websocket messages to many listeners; (c) yielding rows of a paginated DB query that requires await per page.

Show solution

(a) sync* — pure synchronous lazy sequence, no awaiting. (b) StreamController (broadcast) — events pushed from outside, multiple listeners. (c) async* — your own loop, but you must await each page between yields.


Questions to test yourself

Q1 (basic). What does sync* return, and what does async* return?

Show answer

A sync* generator returns an Iterable<T>; an async* generator returns a Stream<T>. Both use yield to emit values one at a time.

Q2 (basic). What does the yield keyword do inside a generator?

Show answer

yield emits a single value into the sequence and pauses the generator at that point. The body resumes from exactly there when the consumer requests the next value (next iteration / next stream event).

Q3 (intermediate). What's the difference between yield and yield*?

Show answer

yield emits one value. yield* ("yield-each") delegates to another iterable or stream, emitting all of its values in place — ideal for recursion and for splicing one sequence into another. It's also more efficient than manually looping and re-yielding.

Q4 (intermediate). Why can a sync* generator represent an infinite sequence without hanging?

Show answer

Because generators are lazy: the body only runs far enough to produce the values actually requested, pausing at each yield. A while (true) yield ... only advances when the consumer pulls, so consuming it with take(n) produces exactly n values and stops. (Building a List instead would try to compute everything and hang.)

Q5 (advanced). What happens to an async* generator's body when its stream listener cancels?

Show answer

When the listener cancels, the next time the body reaches a yield, that yield behaves like a return — the generator stops executing and the stream ends cleanly. You don't need manual cleanup of the loop; the async-generator machinery handles cancellation for you (which is one reason async* is often nicer than a hand-rolled StreamController).

Q6 (advanced). You need to emit a sequence where each element requires an await. Can you use sync*? What should you use, and why?

Show answer

No — sync* is synchronous and doesn't allow await in its body. Use async*, which returns a Stream and lets you await between yields (e.g. fetch a page, then yield its rows). The need to await between elements is exactly the signal to move from sync* to async*.


Wrapping up

Generators let your sequential code be a lazy sequence:

  • sync* produces an Iterable (synchronous, lazy, can be infinite); async* produces a Stream (can await between events).
  • yield emits one value and pauses; yield* splices in an entire iterable/stream — perfect for recursion.
  • Generators are lazy: they compute only what's consumed, so infinite and paginated sequences are natural.
  • async* auto-handles start/close/cancel, making it cleaner than a StreamController when the sequence comes from your own loop.
  • sync* forbids await; needing to wait between elements means you want async*.

We've now built and consumed both futures and streams. But real systems fail — networks drop, parsers choke, files vanish. Handling those failures correctly across await, Future, and Stream is its own skill. Part 7 tackles error handling in async Dart — the subtle rules that decide whether your try/catch actually catches.