Skip to content
C Codeloom
JavaScript

JavaScript Generators and Iterators

A practical guide to JavaScript iterators and generator functions: the protocols, lazy sequences, async generators, and where they shine in real code.

·5 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • The iterator and iterable protocols
  • How generator functions pause and resume
  • Building lazy infinite sequences
  • Async generators for streaming data
  • Where generators are worth the cost

Prerequisites

  • Comfortable with JavaScript functions

Iterators and generators are how JavaScript expresses “a sequence of values you can step through, one at a time.” for...of works on them, the spread operator consumes them, and yield lets you write them by hand without managing state yourself. They are an underused tool for streaming data, lazy evaluation, and clean control flow.

What and why

An iterator is any object with a next() method that returns { value, done }. An iterable is any object that returns an iterator from its [Symbol.iterator]() method. Arrays, strings, Map, Set, and NodeList are all iterable.

A generator function (function*) is a way to build iterators without writing the boilerplate. Each yield pauses the function and returns a value to the caller. Calling next() resumes execution until the next yield or return. That suspended state — the local variables, the position in the function — is preserved between calls.

The “why” is laziness. Generators only compute the next value when asked. You can model infinite sequences, stream a huge file line by line, or build pipelines that process one element at a time without materializing intermediate arrays.

Mental model

A generator is a pausable function. Each call to next() runs the function until it hits a yield, hands the value to you, and freezes. The next call thaws it and continues.

gen.next() -> run until yield -> { value, done: false }
gen.next() -> resume until next yield -> { value, done: false }
gen.next() -> resume until return -> { value, done: true }

State preserved across pauses:
- local variables
- position in code
- try/catch context
Generator execution

Hands-on example

A simple range generator:

function* range(start, end, step = 1) {
  for (let i = start; i < end; i += step) {
    yield i;
  }
}

for (const n of range(0, 5)) console.log(n); // 0,1,2,3,4
console.log([...range(0, 3)]); // [0,1,2]

No array is built. range(0, 1_000_000) is cheap because it generates on demand.

A custom iterable class:

class Stack {
  constructor() { this.items = []; }
  push(x) { this.items.push(x); }
  *[Symbol.iterator]() {
    for (let i = this.items.length - 1; i >= 0; i--) yield this.items[i];
  }
}

const s = new Stack();
s.push(1); s.push(2); s.push(3);
console.log([...s]); // [3, 2, 1]

Because Stack defines [Symbol.iterator], it works with for...of, spread, and destructuring.

Generators can also receive values back from next():

function* dialog() {
  const name = yield 'What is your name?';
  const age = yield `Hello ${name}, how old are you?`;
  return `${name} is ${age}`;
}

const d = dialog();
console.log(d.next().value);       // What is your name?
console.log(d.next('Yash').value); // Hello Yash, how old are you?
console.log(d.next(28).value);     // Yash is 28

This bidirectional communication is what makes generators useful for coroutines and state machines.

Async generators

async function* lets you yield asynchronously. The consumer uses for await...of.

async function* readLines(stream) {
  let buffer = '';
  for await (const chunk of stream) {
    buffer += chunk;
    let newlineIndex;
    while ((newlineIndex = buffer.indexOf('\n')) >= 0) {
      yield buffer.slice(0, newlineIndex);
      buffer = buffer.slice(newlineIndex + 1);
    }
  }
  if (buffer) yield buffer;
}

for await (const line of readLines(process.stdin)) {
  console.log('line:', line);
}

This pattern is the natural way to consume Node streams without callbacks or transforming everything into arrays.

Common pitfalls

  • Iterating a generator twice. Generators are single-use. Once exhausted, next() returns { done: true } forever. Create a new one if you need to iterate again.
  • Forgetting that yield is an expression. Its value comes from the next next(value) call, not from the right-hand side.
  • Treating arrays of promises like async iterables. They are not. Use Promise.all or wrap in an async generator.
  • Throwing without a try/catch inside the generator. The error propagates to the caller of next(), not silently.

Best practices

  • Use generators for streaming, pagination, and pipelines where laziness is a real benefit.
  • For small finite collections, plain arrays are simpler. Do not reach for generators just because they look elegant.
  • Compose generators by delegating with yield*:
function* numbers() { yield 1; yield 2; yield* range(3, 6); }
  • Pair async generators with AbortSignal to support cancellation cleanly.

FAQ

Can I use return inside a generator? Yes. It produces a final { value, done: true } and ends the generator.

Are generators slower than plain arrays? Yes, per-element, due to the protocol overhead. They win when you can avoid materializing large intermediate collections.

Can I await inside a regular generator? No, only inside async function*. A regular generator pauses on yield, not on promises.

How do generators relate to async/await? async/await started as a syntactic restriction of generators that always yield promises. They share the same pause/resume mechanism conceptually.