Iterators and Generators in JavaScript
A deep dive into JavaScript iterators and generators: the iterator protocol, Symbol.iterator, generator functions, yield, async generators, and practical patterns for lazy evaluation and data pipelines.
What you'll learn
- ✓How the iterator protocol works under the hood
- ✓Writing custom iterables with Symbol.iterator
- ✓Generator functions, yield, and yield*
- ✓Async generators for streaming data
- ✓Practical patterns: lazy evaluation, pipelines, infinite sequences
Prerequisites
- •Solid JavaScript fundamentals
- •Familiarity with for...of loops
- •Basic understanding of async/await
Most JavaScript developers use iterators every day without realizing it. Every for...of loop, every spread operator, every destructuring assignment relies on the iterator protocol. Understanding how that protocol works opens the door to lazy evaluation, infinite sequences, and composable data pipelines that process millions of records without blowing up memory.
The iterator protocol
An iterator is any object with a next() method that returns { value, done }. That is the entire contract. Nothing else is required.
const counter = {
current: 0,
next() {
if (this.current < 3) {
return { value: this.current++, done: false };
}
return { value: undefined, done: true };
},
};
console.log(counter.next()); // { value: 0, done: false }
console.log(counter.next()); // { value: 1, done: false }
console.log(counter.next()); // { value: 2, done: false }
console.log(counter.next()); // { value: undefined, done: true }
Each call to next() returns the next value in the sequence. When done is true, the iterator is exhausted. The protocol is stateful: calling next() advances the internal cursor and there is no way to rewind without creating a new iterator.
The iterable protocol and Symbol.iterator
An object is iterable if it has a [Symbol.iterator]() method that returns an iterator. Arrays, strings, maps, and sets all implement this protocol. The for...of loop calls Symbol.iterator behind the scenes.
const range = {
from: 1,
to: 5,
[Symbol.iterator]() {
let current = this.from;
const last = this.to;
return {
next() {
if (current <= last) {
return { value: current++, done: false };
}
return { value: undefined, done: true };
},
};
},
};
for (const num of range) {
console.log(num); // 1, 2, 3, 4, 5
}
// Spread also works because it uses the iterator protocol
const arr = [...range]; // [1, 2, 3, 4, 5]
// So does destructuring
const [first, second] = range; // 1, 2
The key detail: Symbol.iterator returns a new iterator each time. This means you can iterate over the same iterable multiple times. If the method returned the same iterator object, the second loop would see an exhausted iterator and produce nothing.
Built-in iterables
JavaScript ships with several built-in iterables. Each one exposes a slightly different iterator behavior.
// Arrays iterate over values
for (const val of [10, 20, 30]) {
console.log(val); // 10, 20, 30
}
// Strings iterate over Unicode code points (not code units)
for (const char of "hi 🎉") {
console.log(char); // "h", "i", " ", "🎉"
}
// Maps iterate over [key, value] pairs
const map = new Map([["a", 1], ["b", 2]]);
for (const [key, value] of map) {
console.log(key, value); // "a" 1, "b" 2
}
// Sets iterate over values in insertion order
const set = new Set([3, 1, 4, 1, 5]);
for (const val of set) {
console.log(val); // 3, 1, 4, 5
}
Plain objects are not iterable by default. Attempting for...of on a plain object throws a TypeError. You can make an object iterable by adding Symbol.iterator, or just use Object.entries() which returns an array.
Generator functions
Writing iterators by hand is tedious. Generator functions produce iterators automatically. A generator function is declared with function* and uses yield to produce values.
function* countdown(n) {
while (n > 0) {
yield n;
n--;
}
}
const iter = countdown(3);
console.log(iter.next()); // { value: 3, done: false }
console.log(iter.next()); // { value: 2, done: false }
console.log(iter.next()); // { value: 1, done: false }
console.log(iter.next()); // { value: undefined, done: true }
// Generators are iterable
for (const n of countdown(5)) {
console.log(n); // 5, 4, 3, 2, 1
}
When a generator function is called, it does not execute the body. Instead, it returns a generator object that conforms to both the iterator and iterable protocols. The body runs only when next() is called, and execution pauses at each yield.
yield pauses execution
The most important thing about yield is that it suspends the generator. Local variables, the call stack position, everything is preserved. The next call to next() resumes from exactly where it left off.
function* demo() {
console.log("before first yield");
yield 1;
console.log("after first yield, before second");
yield 2;
console.log("after second yield");
}
const g = demo();
g.next(); // logs "before first yield", returns { value: 1, done: false }
g.next(); // logs "after first yield, before second", returns { value: 2, done: false }
g.next(); // logs "after second yield", returns { value: undefined, done: true }
You can also send values into a generator. The argument to next() becomes the result of the yield expression inside the generator.
function* accumulator() {
let total = 0;
while (true) {
const value = yield total;
total += value;
}
}
const acc = accumulator();
acc.next(); // { value: 0, done: false } — primes the generator
acc.next(10); // { value: 10, done: false }
acc.next(20); // { value: 30, done: false }
acc.next(5); // { value: 35, done: false }
The first next() call runs the generator until the first yield. The value passed to that first call is always discarded, which is why we call it “priming.”
yield* for delegation
yield* delegates to another iterable. It pulls every value from the inner iterable and yields them one at a time through the outer generator.
function* concat(...iterables) {
for (const iterable of iterables) {
yield* iterable;
}
}
const result = [...concat([1, 2], [3, 4], [5])];
console.log(result); // [1, 2, 3, 4, 5]
// Works with other generators too
function* innerGen() {
yield "a";
yield "b";
}
function* outerGen() {
yield 1;
yield* innerGen();
yield 2;
}
console.log([...outerGen()]); // [1, "a", "b", 2]
yield* also forwards the return value of the inner generator. If the inner generator uses return, that value becomes the result of the yield* expression.
Infinite sequences
Generators are perfect for infinite sequences because they are lazy. Values are computed only when requested. No array is ever allocated in memory.
function* naturals(start = 1) {
let n = start;
while (true) {
yield n++;
}
}
function* take(iterable, count) {
let i = 0;
for (const value of iterable) {
if (i >= count) return;
yield value;
i++;
}
}
function* filter(iterable, predicate) {
for (const value of iterable) {
if (predicate(value)) {
yield value;
}
}
}
function* map(iterable, fn) {
for (const value of iterable) {
yield fn(value);
}
}
// Compose: first 5 even squares starting from 1
const pipeline = take(
map(
filter(naturals(), (n) => n % 2 === 0),
(n) => n * n
),
5
);
console.log([...pipeline]); // [4, 16, 36, 64, 100]
This processes numbers one at a time. naturals() generates endlessly, filter skips odds, map squares, and take stops after 5 results. At no point does any intermediate array exist.
Async generators
Async generators combine generators with async/await. They are declared with async function* and can yield promises. You consume them with for await...of.
async function* fetchPages(baseUrl) {
let page = 1;
while (true) {
const response = await fetch(`${baseUrl}?page=${page}`);
const data = await response.json();
if (data.results.length === 0) return;
yield data.results;
page++;
}
}
// Consume all pages lazily
async function getAllUsers() {
const users = [];
for await (const page of fetchPages("/api/users")) {
users.push(...page);
}
return users;
}
Async generators are ideal for paginated APIs, streaming responses, and event processing. Each yield pauses until the consumer asks for the next value, which means you only fetch the next page when the previous one has been processed.
Async iteration over streams
Node.js readable streams implement the async iterable protocol. This makes async generators a natural fit for stream processing.
import { createReadStream } from "fs";
import { createInterface } from "readline";
async function* readLines(filePath) {
const stream = createReadStream(filePath, { encoding: "utf-8" });
const rl = createInterface({ input: stream, crlfDelay: Infinity });
for await (const line of rl) {
yield line;
}
}
async function* parseCSV(lines) {
let headers = null;
for await (const line of lines) {
const fields = line.split(",");
if (!headers) {
headers = fields;
continue;
}
const row = {};
headers.forEach((h, i) => (row[h.trim()] = fields[i]?.trim()));
yield row;
}
}
async function* filterRows(rows, predicate) {
for await (const row of rows) {
if (predicate(row)) {
yield row;
}
}
}
// Pipeline: read CSV, parse, filter — all streaming
const lines = readLines("./data.csv");
const rows = parseCSV(lines);
const active = filterRows(rows, (r) => r.status === "active");
for await (const row of active) {
console.log(row.name, row.email);
}
This processes a CSV file of any size without loading it into memory. Each line flows through the pipeline one at a time.
Generator return and throw
Generators support return() and throw() methods for early termination and error injection.
function* controlled() {
try {
yield 1;
yield 2;
yield 3;
} finally {
console.log("cleanup");
}
}
const g = controlled();
g.next(); // { value: 1, done: false }
g.return("early"); // logs "cleanup", returns { value: "early", done: true }
g.next(); // { value: undefined, done: true }
return() forces the generator to execute its finally block and terminate. throw() injects an error at the point where the generator is paused.
function* resilient() {
while (true) {
try {
const value = yield;
console.log("received:", value);
} catch (err) {
console.log("error caught:", err.message);
}
}
}
const r = resilient();
r.next(); // prime
r.next("hello"); // "received: hello"
r.throw(new Error("something bad")); // "error caught: something bad"
r.next("still alive"); // "received: still alive"
Practical pattern: stateful tokenizer
Generators are excellent for stateful parsing because they maintain their position between calls.
function* tokenize(input) {
const patterns = [
{ type: "NUMBER", regex: /^\d+(\.\d+)?/ },
{ type: "STRING", regex: /^"[^"]*"/ },
{ type: "IDENT", regex: /^[a-zA-Z_]\w*/ },
{ type: "OP", regex: /^[+\-*/=<>!]+/ },
{ type: "PAREN", regex: /^[()]/ },
{ type: "WS", regex: /^\s+/ },
];
let pos = 0;
while (pos < input.length) {
let matched = false;
for (const { type, regex } of patterns) {
const match = input.slice(pos).match(regex);
if (match) {
if (type !== "WS") {
yield { type, value: match[0], pos };
}
pos += match[0].length;
matched = true;
break;
}
}
if (!matched) {
throw new Error(`Unexpected character at position ${pos}: ${input[pos]}`);
}
}
}
for (const token of tokenize('x = 42 + y')) {
console.log(token);
}
// { type: "IDENT", value: "x", pos: 0 }
// { type: "OP", value: "=", pos: 2 }
// { type: "NUMBER", value: "42", pos: 4 }
// { type: "OP", value: "+", pos: 7 }
// { type: "IDENT", value: "y", pos: 9 }
Making a class iterable
You can make any class work with for...of by implementing Symbol.iterator.
class LinkedList {
constructor() {
this.head = null;
}
append(value) {
const node = { value, next: null };
if (!this.head) {
this.head = node;
return;
}
let current = this.head;
while (current.next) current = current.next;
current.next = node;
}
*[Symbol.iterator]() {
let current = this.head;
while (current) {
yield current.value;
current = current.next;
}
}
}
const list = new LinkedList();
list.append("a");
list.append("b");
list.append("c");
for (const val of list) {
console.log(val); // "a", "b", "c"
}
console.log([...list]); // ["a", "b", "c"]
Using a generator method (*[Symbol.iterator]()) is the cleanest approach. The generator handles state tracking automatically.
When to use generators
Generators shine in a few specific scenarios. Use them when you need lazy evaluation over large or infinite datasets, when you want composable data pipelines without intermediate arrays, when you are streaming data from I/O sources, or when you need cooperative multitasking with explicit yield points.
Avoid generators when you just need a simple array transformation (use map/filter), when the entire dataset fits comfortably in memory, or when the overhead of the generator protocol is not justified by the laziness benefit.
The iterator and generator protocols are one of JavaScript’s most underused features. They solve real problems around memory, composability, and streaming that arrays cannot. Once you recognize those problems, generators become an obvious tool to reach for.
Related articles
- JavaScript JavaScript Symbols and Well-Known Symbols
Master JavaScript Symbols for unique property keys, explore well-known symbols like Symbol.iterator and Symbol.toPrimitive, and learn metaprogramming patterns.
- JavaScript Generators, Iterators, and Symbol.iterator in JavaScript
Master JavaScript generators and iterators -- learn function*, yield, Symbol.iterator, and how to build custom iterable objects.
- JavaScript Web Workers in JavaScript
Run JavaScript in background threads with Web Workers — dedicated workers, shared workers, message passing, and offloading heavy computation.
- JavaScript JavaScript Closures Explained with Real Examples
A practical guide to JavaScript closures, lexical scope, the classic loop bug, and the patterns that make closures genuinely useful in production code.