Skip to content

Generators in JavaScript - a function that knows how to pause

Profile photo of Adrian Zawadzki

Adrian Zawadzki

11 min read

A regular JS function runs top to bottom, returns a value, and disappears from memory. A generator is different - marked with function*, it pauses on every yield, hands a value out, and waits for the next .next(). A small change in the runtime, but it's the one that powers on-demand sequences and lives under the hood of async/await.

Most of the data you work with in JS lives in ready-made collections - arrays, Sets, Maps. You loop through them, every element already sits in memory, computed up-front.

Sometimes that’s not enough - because the data isn’t there yet, there’s too much of it to keep in memory, or each element costs something (a request, a computation) and you don’t know whether you’ll even use it.

A generator is something different: a function that produces values one at a time, on demand. It doesn’t return a single result at the end - it hands out chunks along the way. You ask via .next(), it answers, and in between it remembers where it left off.

#A function that knows how to pause

A regular JS function is short-lived. You call it, it goes onto the call stack, runs top to bottom, returns a value, and disappears. There’s no “halfway” state.

Generators change two things about that. First - the syntax function* (with the asterisk) and the yield keyword, which works like return with one difference: it preserves the function’s context until the next call. Second - calling the generator doesn’t actually run its code. It only returns an object (a generator object) with a .next() method that will run the body for us:

function* counter() {
  console.log("starting");
  yield 1;
  console.log("moving on");
  yield 2;
  console.log("last step");
  yield 3;
}

const it = counter();
console.log("after counter() was called");
console.log(it.next());

// after counter() was called    ← this prints first
// starting                       ← only now the body actually runs
// { value: 1, done: false }

The body started running only after the first .next(). Calling the generator doesn’t run the code - it returns an object (assigned above to it) that’s about to do that, step by step.

#Each .next() is one step

.next() returns an object { value, done }:

  • value - what the generator emitted via yield (or returned via return, or undefined).
  • done - false while the function hasn’t finished yet; true once it has reached the end.
const it = counter();
it.next(); // { value: 1, done: false }
it.next(); // { value: 2, done: false }
it.next(); // { value: 3, done: false }
it.next(); // { value: undefined, done: true }

Each .next() runs a chunk of the function up to the next yield, hands the value out, and pauses. Once there are no more yields, subsequent .next() calls return { value: undefined, done: true } - no error, the generator is just “exhausted”.

#What “suspended context” means

When the generator runs yield x, its execution context doesn’t disappear. Local variables, the position in the code, the scope chain - all of it gets preserved, ready to resume.

function* state() {
  let x = 10;
  yield x;
  x += 5;
  yield x;
}

const it = state();
it.next(); // { value: 10, done: false }
it.next(); // { value: 15, done: false }   <- x survived the first yield

Most things at runtime are binary: a context either exists on the stack or it doesn’t. A generator has a third state: the context exists, but it’s suspended. It’s waiting for a signal to keep going.

#Two-way communication: .next(value)

Here’s where it gets interesting. yield isn’t only an emission outward. It’s also an expression into which a value can be substituted via .next(value).

The cleanest way to see it is by clicking. Below you have a real dialog() generator - click .next() (the first call ignores its argument), then type values and hit .next("..."). Watch the cursor on the left (where the generator stands suspended) and the locals panel on the right (how your input lands in name, then in age):

code
function* dialog() {  const name = yield "What's your name?";  const age = yield `Hi ${name}, how old are you?`;  return `${name}, ${age} years old`;} const it = dialog();   // <- the iterator you call .next() on
stateready
locals
none
call history
click .next() to start the generator

Step by step:

  1. The first .next() starts the generator and runs all the way to yield "What's your name?". Pause. value is that string.
  2. .next("Adam") resumes the function. The value "Adam" is substituted into the yield "What's your name?" expression, so name = "Adam". The function keeps going.
  3. .next(30) works the same way - 30 lands as the result of the second yield, age = 30.

The argument passed to the first .next() is ignored, because the generator isn’t sitting on any yield yet - there’s nothing to substitute the value into.

This is exactly the channel async/await needed. Imagine a small helper function (typically called an auto-runner) sitting next to the generator and calling .next() for you in a loop. When the generator pauses on yield <some Promise>, the auto-runner waits for that Promise to settle and then substitutes the result back via .next(result). The generator sees that value on the right side of the yield expression - which is exactly where, in an async function, you write await. The whole magic of async/await, tucked into a single keyword.

#Iterator protocol - why for...of works

for...of, spread, and destructuring all work the same way: they call .next() on the object in a loop until they get done: true. So all you need is for an object to have .next() returning { value, done } and JS knows how to iterate it. These rules are called the iterator protocol.

A generator object satisfies them out of the box, so it fits anywhere JS expects something iterable:

for (const n of counter()) console.log(n); // 1, 2, 3

[...counter()]; // [1, 2, 3]
const [a, b] = counter(); // a=1, b=2
Array.from(counter()); // [1, 2, 3]

A generator is one-shot. Once it reaches done: true, you can’t “rewind” it. To get a fresh iterator, you call the generator function again - e.g. dialog().

#Two use cases: data on demand and the foundation under async/await

The first use case: data you don’t want to keep in memory all at once. An array requires every element to exist up front. A generator doesn’t - it computes the next value only when someone asks for it. The pattern is known in theory as lazy evaluation:

function* naturals() {
  let n = 1;
  while (true) {
    yield n;
    n += 1;
  }
}

const it = naturals();
it.next().value; // 1
it.next().value; // 2
it.next().value; // 3
// ...you could go on forever - the generator computes a value
// only when someone asks for it via .next()

What you get out of it: two things. First, you can have a sequence that wouldn’t fit in an array - infinite (Fibonacci, an event stream) or too big to fit in memory (a 10 GB file). Second, memory stays constant: a generator only holds its current state, so reading a 10 GB file line by line costs about as much memory as 10 KB.

The second use case is the foundation under async/await. Under the hood, async function is the exact same pattern from the section above: a generator + an auto-runner waiting on Promise. await is just yield. Before native async/await existed, this was assembled by hand from generators - e.g. with the co library.

#Takeaway

It comes down to three things:

  1. A generator is a function that, between consecutive calls, remembers where it stopped. function* + yield signals to the runtime that it should preserve the entire execution state of the function - the position in the code, local variables, the scope chain - so that the next .next() can resume the function exactly from that spot. This is a different thing than closure: closure remembers variables, a generator remembers the entire execution state.

  2. Each .next() is one step of execution. Values flow in both directions, along two separate channels:

    • Output: you write yield X inside the generator, you read X from .next().value outside.
    • Input: you write .next(Y) outside, the generator gets Y as the result of the yield expression.

    That’s why a generator doesn’t only produce data - it also consumes it.

  3. A few things are built on top of this mechanism: data on demand, memory savings, custom iterators, and above all async/await - which under the hood is a generator with yield <Promise> inside, driven by an auto-runner.

Comments

Loading comments…

Leave a comment