JavaScript · Functions

JavaScript Generators & Iterators Explained — Lazy Sequences and the Iteration Protocols

6 min read Updated 2026-06-18

Practice Generators & Iterators interview questions

Lazy, pausable functions

Most functions run start to finish in one shot. Generators are different: they can pause mid-execution and resume later, producing a sequence of values on demand. This makes them ideal for lazy evaluation, infinite sequences, and streaming data without loading everything into memory. Generators are built on JavaScript's iteration protocols, the same machinery that powers for...of, spread, and destructuring. Understanding both gives you a powerful, often-underused tool — and explains how iteration works throughout the language.

The iterator protocol

An iterator is any object with a next() method that returns { value, done }. Each call advances the sequence; done: true signals the end.

const it = {
  i: 0,
  next() {
    return this.i < 3
      ? { value: this.i++, done: false }
      : { value: undefined, done: true }   // terminator
  }
}
it.next()   // { value: 0, done: false }
it.next()   // { value: 1, done: false }

This simple contract is what every iteration construct ultimately calls.

The iterable protocol

An iterable is an object with a [Symbol.iterator]() method that returns an iterator. The iterable is the collection; the iterator is the cursor. for...of, spread, Array.from, and destructuring all look for Symbol.iterator.

const range = {
  from: 1, to: 3,
  [Symbol.iterator]() {
    let current = this.from
    const last = this.to
    return {
      next: () => current <= last
        ? { value: current++, done: false }
        : { value: undefined, done: true }
    }
  }
}
[...range]                  // [1, 2, 3]
for (const n of range) {}   // works

Built-ins like arrays, strings, Map, and Set all implement this protocol — which is why they work with for...of. Plain objects don't, which is why for...of {} throws.

Generators: protocols made easy

Writing iterators by hand is tedious. A generator function (function*) produces an object that is both an iterator and an iterable, managing all the { value, done } bookkeeping for you. Each yield pauses and emits a value.

function* gen() {
  yield 1
  yield 2
  yield 3
}
const g = gen()
g.next()   // { value: 1, done: false }
[...gen()] // [1, 2, 3] spreadable directly

Calling a generator doesn't run its body — it returns a generator object. The body advances only when you call next() (or iterate). This pause/resume is the entire point.

Rewriting range as a generator

Compare the verbose manual iterator above with the generator version — the difference is dramatic.

function* range(from, to) {
  for (let i = from; i <= to; i++) yield i   // all bookkeeping handled
}
[...range(1, 3)]   // [1, 2, 3]

To make a class iterable, define [Symbol.iterator] as a generator method and yield the elements — no next()/done plumbing required.

Lazy and infinite sequences

Because generators compute values only when asked, they can represent infinite sequences safely, as long as the consumer stops pulling.

function* naturals() {
  let n = 1
  while (true) yield n++       // infinite, but lazy
}

const it = naturals()
it.next().value   // 1 — only this much computed
it.next().value   // 2

The pitfall: [...naturals()] or a for...of without a break will loop forever. Always bound consumption (a take(n) helper, a break, or destructuring a fixed count).

yield* — delegation

yield* delegates to another iterable, yielding all of its values inline. It's perfect for composing generators or flattening structures.

function* inner() { yield 1; yield 2 }
function* outer() {
  yield 0
  yield* inner()   // yields 1, then 2
  yield 3
}
[...outer()]   // [0, 1, 2, 3]

yield* works on any iterable (arrays, strings, Sets), making recursive flattening elegant: yield* flatten(child) for nested data.

Two-way communication

Generators aren't just producers — they can receive values. The argument to next(value) becomes the result of the yield expression the generator is paused on.

function* conversation() {
  const name = yield 'What is your name?'   // receives the next() argument
  yield `Hello, ${name}!`
}
const c = conversation()
c.next().value        // 'What is your name?'
c.next('Ada').value   // 'Hello, Ada!' 'Ada' became `name`

Note the first next() argument is ignored — there's no paused yield yet to receive it. This two-way channel enables coroutine-style control flow.

Early termination and cleanup

for...of automatically calls the generator's return() when you break or throw, running any finally block — so resources get released.

function* withResource() {
  try {
    while (true) yield acquire()
  } finally {
    release()   // runs even on early break
  }
}
for (const x of withResource()) { if (done) break }   // release() called

This makes generators a clean way to manage resources tied to iteration.

Single-use iterators

A generator object is a one-shot iterator — once exhausted, it stays done and won't restart.

const g = (function* () { yield 1; yield 2 })()
[...g]   // [1, 2]
[...g]   // [] already consumed

To re-iterate, expose a factory (a function returning a fresh generator) or a class whose [Symbol.iterator] creates a new one each call.

Async generators

An async function* can await inside and yield asynchronously; you consume it with for await...of. This is ideal for paginated APIs and streams where each chunk arrives over time.

async function* pages(url) {
  let next = url
  while (next) {
    const res = await fetch(next)      // await inside
    const data = await res.json()
    yield data.items
    next = data.nextPage
  }
}
for await (const items of pages('/api/items')) {
  process(items)
}

Async generators combine lazy iteration with asynchrony — streaming data on demand, one awaited chunk at a time.

When to use generators

Reach for generators when you need laziness (compute on demand), infinite or streaming sequences, memory-bounded processing of huge data, or two-way coroutine control. For small, fully-consumed collections, plain arrays and array methods are simpler and faster to index. Generators add per-next() overhead and can't be randomly accessed or reused — so use them where their unique powers actually pay off.

Key takeaways

  • An iterator has next() returning { value, done }; an iterable has [Symbol.iterator]() returning an iterator — the basis of for...of, spread, destructuring.
  • Generators (function* with yield) produce objects that are both, handling all protocol bookkeeping and pausing/resuming on demand.
  • They enable lazy and infinite sequences — but bound consumption or you'll loop forever.
  • yield* delegates to other iterables; next(value) enables two-way communication.
  • Generators are single-use; expose a factory to re-iterate. for...of triggers cleanup via return().
  • Async generators + for await...of stream asynchronous data like paginated APIs.

Generators turn iteration into something you control step by step — lazy, composable, and memory-friendly — and reveal the protocols that make all JavaScript iteration tick.

Practice tests are coming soon

Get notified when interactive mock interviews and quizzes launch.