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.
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 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
yieldis an expression. Its value comes from the nextnext(value)call, not from the right-hand side. - Treating arrays of promises like async iterables. They are not. Use
Promise.allor wrap in an async generator. - Throwing without a
try/catchinside the generator. The error propagates to the caller ofnext(), 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
AbortSignalto 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.
Related articles
- Node.js Node.js Async Iterators Tutorial
Master async iterators in Node.js for streaming files, paginated APIs, and backpressure-aware data processing.
- JavaScript The JavaScript Event Loop, Explained Clearly
A deep but practical look at the JavaScript event loop: call stack, microtasks, macrotasks, and how async code actually runs in the browser and Node.
- JavaScript JavaScript Async/Await Tutorial
Master async and await in JavaScript — write asynchronous code that reads like synchronous code, handle errors with try/catch, and parallelize work with Promise.all.
- JavaScript JavaScript Promises Deep Dive
Go beyond the basics of JavaScript promises — explore the microtask queue, error propagation, cancellation patterns, and advanced combinators like any and allSettled.