ES6
generators
iterators
callbacks
JavaScript

ES6 generators transforming callbacks to iterators

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Before async and await became the standard style for asynchronous JavaScript, generators were often used to turn nested callbacks into code that looked sequential. The core idea is simple: a generator pauses at each async step, and a small runner resumes it when the next value is ready.

Start with the Callback Problem

Callback-based code spreads control flow across several nested functions. Error handling and data dependencies get harder to follow as the sequence grows.

javascript
1function readUser(id, callback) {
2  setTimeout(() => callback(null, { id, name: "Ava" }), 50);
3}
4
5function readOrders(userId, callback) {
6  setTimeout(() => callback(null, ["o1", "o2"]), 50);
7}
8
9readUser(1, (userError, user) => {
10  if (userError) return console.error(userError);
11
12  readOrders(user.id, (orderError, orders) => {
13    if (orderError) return console.error(orderError);
14    console.log(user, orders);
15  });
16});

This is manageable with two steps, but it gets messy quickly.

Use a Generator as a Pause-and-Resume State Machine

A generator returns an iterator. Each yield pauses execution and hands control back to the caller. When the caller later calls next(value), that value becomes the result of the suspended yield expression.

javascript
1function* flow() {
2  const first = yield "step one";
3  const second = yield `step two after ${first}`;
4  return `done with ${second}`;
5}
6
7const iterator = flow();
8console.log(iterator.next());
9console.log(iterator.next("A"));
10console.log(iterator.next("B"));

That pause-and-resume behavior is exactly what makes generators useful for async orchestration.

Wrap Callback APIs and Run the Generator

The common pattern is to wrap old callback APIs in promises, then write a runner that feeds resolved values back into the generator.

javascript
1function readUserPromise(id) {
2  return new Promise((resolve, reject) => {
3    readUser(id, (error, user) => {
4      if (error) reject(error);
5      else resolve(user);
6    });
7  });
8}
9
10function readOrdersPromise(userId) {
11  return new Promise((resolve, reject) => {
12    readOrders(userId, (error, orders) => {
13      if (error) reject(error);
14      else resolve(orders);
15    });
16  });
17}
18
19function run(generatorFunction) {
20  const iterator = generatorFunction();
21
22  function step(nextFunction, value) {
23    let result;
24
25    try {
26      result = nextFunction.call(iterator, value);
27    } catch (error) {
28      return Promise.reject(error);
29    }
30
31    if (result.done) {
32      return Promise.resolve(result.value);
33    }
34
35    return Promise.resolve(result.value).then(
36      resolved => step(iterator.next, resolved),
37      error => step(iterator.throw, error)
38    );
39  }
40
41  return step(iterator.next);
42}
43
44function* main() {
45  const user = yield readUserPromise(1);
46  const orders = yield readOrdersPromise(user.id);
47  return { user, orders };
48}
49
50run(main).then(console.log).catch(console.error);

The generator now reads like straight-line logic even though the work is still asynchronous underneath.

Why This Pattern Still Matters

Modern JavaScript code should usually use async and await for this job. Even so, generator runners still matter for two reasons:

  • older codebases may already use them
  • they help explain how sequential-looking async control flow can be built from iterators and promises

Understanding this pattern makes libraries such as co much less mysterious and makes async functions easier to appreciate, because async and await solve the same control-flow problem more directly.

Common Pitfalls

The biggest mistake is thinking a generator is asynchronous by itself. It is not. A generator only pauses and resumes. You still need a runner or some external code to drive the iterator.

Another common bug is forgetting to send rejected promises into iterator.throw. If the runner only handles successful values, errors disappear or surface in the wrong place.

People also mix callbacks, promises, and generators without a clear boundary. That creates code that is harder to debug than the original callback chain. Once you wrap an API in a promise, keep the rest of the flow consistent.

Finally, do not introduce generators for new async code unless there is a specific reason. async and await are usually clearer.

Summary

  • Generators can turn callback-heavy async flows into sequential-looking code.
  • A generator pauses at yield, and a runner resumes it with the next value.
  • Wrapping callback APIs in promises makes generator runners much easier to write.
  • This pattern is mainly useful for understanding or maintaining older async JavaScript.
  • For new code, prefer async and await unless you specifically need iterator behavior.

Course illustration
Course illustration

All Rights Reserved.