AlgoInsight

Free lesson · JavaScript Basic to Advance

Async Iteration and Generators

Easy5 min read

Looping Over Async Things

JavaScript provides two features for working with sequences of values over time: async iteration (for await...of) and async generators (functions that produce sequences of async values). Both build on the iteration model from regular for...of loops and the function-with-state model from generators.

async function* numberStream() {
  for (let i = 1; i <= 3; i++) {
    // Simulate async work between yields
    await new Promise(resolve => setTimeout(resolve, 50));
    yield i;
  }
}

async function consume() {
  for await (const num of numberStream()) {
    console.log("Got:", num);
  }
  console.log("Stream done");
}

consume();

The output: Got: 1, then 50ms later Got: 2, then 50ms later Got: 3, then Stream done. The async generator produces values over time, and for await...of waits for each one.

This Topic gives you the essentials. Async iteration is a smaller part of the language than the Promise material, but it shows up in specific scenarios — streaming data, reading file chunks, paginated API results, real-time event processing.

Interactive animation: A generator runs nothing until next() is called, then pauses at each yield and remembers its place.

Generators Without async

Before async generators, let me cover regular generators briefly, because async generators build on them.

A generator is a function that can be paused and resumed. You declare one with function* (the asterisk). Inside, you use yield to produce a value and pause. The caller resumes the generator to get the next value.

function* counter() {
  yield 1;
  yield 2;
  yield 3;
}

const it = counter();
console.log(it.next());   // { value: 1, done: false }
console.log(it.next());   // { value: 2, done: false }
console.log(it.next());   // { value: 3, done: false }
console.log(it.next());   // { value: undefined, done: true }

// Or use for...of
for (const value of counter()) {
  console.log("Value:", value);
}

Each yield produces a value and pauses execution. The next call to next() resumes execution from where the last yield left off. When the function ends, the iterator reports done: true.

Generators are useful for producing sequences lazily — you don't compute all values upfront, just one at a time. The classic example is an infinite sequence:

function* fibonacci() {
  let [a, b] = [0, 1];
  while (true) {
    yield a;
    [a, b] = [b, a + b];
  }
}

const fib = fibonacci();
console.log(fib.next().value);   // 0
console.log(fib.next().value);   // 1
console.log(fib.next().value);   // 1
console.log(fib.next().value);   // 2
console.log(fib.next().value);   // 3
console.log(fib.next().value);   // 5

The generator produces Fibonacci numbers indefinitely. You take as many as you need; the rest are never computed.

Interactive animation: An infinite generator of even numbers, pulled five at a time - the rest are never computed.

Async Generators

Async generators combine generators with async. They produce values asynchronously — each yield can wait for a Promise before producing.

async function* fetchPages(maxPage) {
  for (let page = 1; page <= maxPage; page++) {
    // Simulate fetching a page
    const data = await new Promise(resolve => {
      setTimeout(() => resolve({ page, items: [`item ${page}.1`, `item ${page}.2`] }), 50);
    });
    yield data;
  }
}

async function main() {
  for await (const page of fetchPages(3)) {
    console.log("Page:", page);
  }
}

main();

Three pages, each fetched asynchronously. The for await...of loop waits for each page before the next iteration begins. This is the canonical pattern for streaming data — you don't load everything upfront, you process it as it comes.

When Async Iteration Helps

Async iteration is useful when:

Data arrives over time, not all at once. Streaming HTTP responses, server-sent events, WebSocket messages, paginated APIs. You can't get all the data in one Promise — you need to consume it as it comes.

Memory matters. Loading a huge dataset all at once might use too much memory. Async iteration lets you process one chunk at a time, with backpressure naturally controlled by how fast you consume.

The source has natural sequencing. Reading a file line by line, processing a queue, walking a directory tree. The source produces values in order, and you want to handle each in turn.

Real-world examples I've worked with at Netflix include:

A Practical Pattern

A common pattern: async generator that paginates through an API:

async function* paginate(fetchPage, totalPages) {
  for (let p = 1; p <= totalPages; p++) {
    const items = await fetchPage(p);
    for (const item of items) {
      yield item;
    }
  }
}

async function fakeFetchPage(p) {
  await new Promise(r => setTimeout(r, 30));
  return [`item ${p}.1`, `item ${p}.2`, `item ${p}.3`];
}

async function example() {
  let count = 0;
  for await (const item of paginate(fakeFetchPage, 3)) {
    console.log(item);
    count++;
    if (count >= 5) break;   // stop early
  }
  console.log("Done after", count, "items");
}

example();

The generator paginates through 3 pages of 3 items each. The consumer takes the first 5 items and stops. The generator never fetches the third page because the consumer stopped — this is the memory benefit of generators. Work doesn't happen if you don't ask for it.

Interactive animation: An async generator pages through orders behind a simple for await loop, and break stops further pages being fetched.

Common Pitfalls

Warning: The async iteration traps.

Confusing for await...of with for...of over Promises. for...of on an array of Promises doesn't await them — it iterates the Promise objects themselves. To await each, you need for await...of or Promise.all.

Forgetting that async iteration is sequential. Each iteration of for await...of waits for the previous one. If you have independent work, Promise.all parallelizes better.

Breaking out without cleanup. When you break out of an async iteration, the generator's return mechanism should clean up resources. If your generator opens connections or files, make sure it has a try/finally to handle early termination.

Why This Matters in Interviews

Interview: Async iteration is a senior-level topic and less commonly asked than core async material. When it comes up, the question is usually "when would you use async generators?" The expected answer involves streaming data, pagination, memory-efficient processing of large datasets.

Output-prediction puzzles involving for await...of are rare but do appear. The mechanism is the same as for...of but with an await on each iteration.

Topic 9.12 — Interview Lens: Promise and async/await Patterns — covers the broader async interview treatment.

Try It

Exercise: Build an async generator delayedSequence that:

  1. Takes a start value, an end value, and a delayMs between values
  2. Yields each integer from start to end (inclusive), with delayMs between each

Then consume it with a for await...of loop that prints each value with a timestamp.

async function* delayedSequence(start, end, delayMs) {
  // your code
}

async function consume() {
  for await (const value of delayedSequence(1, 5, 100)) {
    console.log(Date.now(), value);
  }
}

consume();

The timestamps between values should differ by roughly delayMs. By the time this works, you've built a real async sequence and consumed it with backpressure.

Back to JavaScript Basic to Advance