JavaScript Iterators and Generators: yield, Lazy Sequences, Async Streams
JavaScript iterators and generators give you fine-grained control over iteration, lazy evaluation, and asynchronous data streams. The iteration protocol is the foundation that powers for...of loops, spread syntax, destructuring, and Array.from(). Generators, using the yield keyword, let you write functions that can pause and resume, producing values on demand. This tutorial covers the iterator protocol, custom iterables, generator functions, async generators, and practical patterns for real-world JavaScript development.
The Iteration Protocol
JavaScript defines two protocols that enable any object to be iterable:
- Iterable protocol: An object is iterable if it has a
[Symbol.iterator]()method that returns an iterator - Iterator protocol: An object is an iterator if it has a
next()method that returns{ value, done }
// Arrays are iterable
const arr = [10, 20, 30];
const iterator = arr[Symbol.iterator]();
console.log(iterator.next()); // { value: 10, done: false }
console.log(iterator.next()); // { value: 20, done: false }
console.log(iterator.next()); // { value: 30, done: false }
console.log(iterator.next()); // { value: undefined, done: true }
These protocols are used by many JavaScript features:
for...ofloops- Spread syntax:
[...iterable] - Destructuring:
const [a, b] = iterable Array.from(iterable)new Map(iterable),new Set(iterable)Promise.all(iterable),Promise.race(iterable)
Iterables vs Iterators
An iterable is a factory that produces iterators. An iterator is a stateful cursor that walks through values.
const str = 'Hello';
// str is an iterable (has Symbol.iterator method)
console.log(typeof str[Symbol.iterator]); // "function"
// Each call creates a new, independent iterator
const it1 = str[Symbol.iterator]();
const it2 = str[Symbol.iterator]();
console.log(it1.next().value); // "H"
console.log(it1.next().value); // "e"
console.log(it2.next().value); // "H" (independent cursor)
Built-in iterables include: String, Array, Map, Set, TypedArray, arguments, and NodeList.
Building Custom Iterators
You can make any object iterable by implementing [Symbol.iterator]():
class Range {
constructor(start, end, step = 1) {
this.start = start;
this.end = end;
this.step = step;
}
[Symbol.iterator]() {
let current = this.start;
const end = this.end;
const step = this.step;
return {
next() {
if (current <= end) {
const value = current;
current += step;
return { value, done: false };
}
return { value: undefined, done: true };
}
};
}
}
const range = new Range(1, 10, 2);
for (const num of range) {
console.log(num); // 1, 3, 5, 7, 9
}
console.log([...range]); // [1, 3, 5, 7, 9]
const [first, second] = range; // first=1, second=3
console.log(Array.from(range)); // [1, 3, 5, 7, 9]
Linked List Iterator
class LinkedList {
constructor() {
this.head = null;
}
add(value) {
this.head = { value, next: this.head };
return this;
}
[Symbol.iterator]() {
let current = this.head;
return {
next() {
if (current) {
const value = current.value;
current = current.next;
return { value, done: false };
}
return { value: undefined, done: true };
}
};
}
}
const list = new LinkedList();
list.add(3).add(2).add(1);
for (const val of list) {
console.log(val); // 1, 2, 3
}
Generator Functions and yield
Generator functions use function* syntax and the yield keyword to create iterators with far less boilerplate:
function* countdown(n) {
while (n > 0) {
yield n;
n--;
}
}
const counter = countdown(5);
console.log(counter.next()); // { value: 5, done: false }
console.log(counter.next()); // { value: 4, done: false }
console.log(counter.next()); // { value: 3, done: false }
// Or use in for...of
for (const num of countdown(3)) {
console.log(num); // 3, 2, 1
}
When a generator encounters yield, it pauses execution and returns the yielded value. When next() is called again, execution resumes from where it paused. This pause/resume behavior is what makes generators special.
function* steps() {
console.log('Step 1');
yield 'first';
console.log('Step 2');
yield 'second';
console.log('Step 3');
return 'done'; // return sets done: true
}
const gen = steps();
console.log(gen.next()); // logs "Step 1", returns { value: 'first', done: false }
console.log(gen.next()); // logs "Step 2", returns { value: 'second', done: false }
console.log(gen.next()); // logs "Step 3", returns { value: 'done', done: true }
Practical Generator Patterns
Simplified Range with Generators
function* range(start, end, step = 1) {
for (let i = start; i <= end; i += step) {
yield i;
}
}
console.log([...range(1, 10)]); // [1,2,3,4,5,6,7,8,9,10]
console.log([...range(0, 100, 25)]); // [0,25,50,75,100]
ID Generator
function* idGenerator(prefix = 'id') {
let n = 1;
while (true) {
yield `${prefix}_${String(n).padStart(6, '0')}`;
n++;
}
}
const userIds = idGenerator('user');
console.log(userIds.next().value); // "user_000001"
console.log(userIds.next().value); // "user_000002"
console.log(userIds.next().value); // "user_000003"
Lazy Map and Filter
function* map(iterable, fn) {
for (const item of iterable) {
yield fn(item);
}
}
function* filter(iterable, predicate) {
for (const item of iterable) {
if (predicate(item)) yield item;
}
}
function* take(iterable, n) {
let count = 0;
for (const item of iterable) {
if (count >= n) return;
yield item;
count++;
}
}
// Compose lazy operations (nothing executes until consumption)
const numbers = range(1, 1000000);
const evens = filter(numbers, n => n % 2 === 0);
const squared = map(evens, n => n * n);
const firstTen = take(squared, 10);
console.log([...firstTen]);
// [4, 16, 36, 64, 100, 144, 196, 256, 324, 400]
// Only 20 numbers were ever generated, not 1,000,000!
This lazy evaluation pattern is extremely memory-efficient. Compare this with array methods like .map() and .filter() which create intermediate arrays.
Infinite Sequences
Generators can represent infinite sequences because values are produced on demand:
function* fibonacci() {
let a = 0, b = 1;
while (true) {
yield a;
[a, b] = [b, a + b];
}
}
// Take first 10 Fibonacci numbers
console.log([...take(fibonacci(), 10)]);
// [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
function* primes() {
const found = [];
let candidate = 2;
while (true) {
if (found.every(p => candidate % p !== 0)) {
found.push(candidate);
yield candidate;
}
candidate++;
}
}
console.log([...take(primes(), 10)]);
// [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
yield* Delegation
yield* delegates iteration to another iterable or generator:
function* concat(...iterables) {
for (const iterable of iterables) {
yield* iterable;
}
}
console.log([...concat([1, 2], [3, 4], [5, 6])]);
// [1, 2, 3, 4, 5, 6]
// Tree traversal with yield*
function* inorder(node) {
if (!node) return;
yield* inorder(node.left);
yield node.value;
yield* inorder(node.right);
}
const tree = {
value: 4,
left: {
value: 2,
left: { value: 1, left: null, right: null },
right: { value: 3, left: null, right: null }
},
right: {
value: 6,
left: { value: 5, left: null, right: null },
right: { value: 7, left: null, right: null }
}
};
console.log([...inorder(tree)]);
// [1, 2, 3, 4, 5, 6, 7]
Two-Way Communication with Generators
The next(value) method can send values into the generator. The sent value becomes the result of the yield expression:
function* conversation() {
const name = yield 'What is your name?';
const hobby = yield `Hello ${name}! What is your hobby?`;
return `${name} enjoys ${hobby}. Goodbye!`;
}
const chat = conversation();
console.log(chat.next()); // { value: "What is your name?", done: false }
console.log(chat.next('Alice')); // { value: "Hello Alice!...", done: false }
console.log(chat.next('coding')); // { value: "Alice enjoys coding...", done: true }
Generator-Based State Machine
function* trafficLight() {
while (true) {
yield 'red';
yield 'green';
yield 'yellow';
}
}
const light = trafficLight();
console.log(light.next().value); // "red"
console.log(light.next().value); // "green"
console.log(light.next().value); // "yellow"
console.log(light.next().value); // "red" (cycles)
Error Handling in Generators
function* safeDivide() {
while (true) {
try {
const [a, b] = yield 'Enter two numbers';
if (b === 0) throw new Error('Division by zero');
yield a / b;
} catch (err) {
yield `Error: ${err.message}`;
}
}
}
const calc = safeDivide();
calc.next(); // "Enter two numbers"
console.log(calc.next([10, 2]).value); // 5
calc.next(); // "Enter two numbers"
console.log(calc.next([10, 0]).value); // "Error: Division by zero"
See the error handling tutorial for more on try/catch patterns.
Async Generators and for-await-of
Async generators combine generators with async/await, enabling iteration over asynchronous data streams:
async function* fetchPages(baseUrl, maxPages) {
let page = 1;
while (page <= maxPages) {
const response = await fetch(`${baseUrl}?page=${page}`);
const data = await response.json();
if (data.results.length === 0) return;
yield data.results;
page++;
}
}
// Consume with for-await-of
async function getAllUsers() {
const allUsers = [];
for await (const page of fetchPages('https://api.example.com/users', 10)) {
allUsers.push(...page);
console.log(`Fetched ${allUsers.length} users so far...`);
}
return allUsers;
}
Real-Time Event Stream
async function* eventStream(url) {
const eventSource = new EventSource(url);
const queue = [];
let resolve;
eventSource.onmessage = (event) => {
queue.push(JSON.parse(event.data));
if (resolve) {
resolve();
resolve = null;
}
};
try {
while (true) {
if (queue.length === 0) {
await new Promise(r => { resolve = r; });
}
yield queue.shift();
}
} finally {
eventSource.close();
}
}
// Usage
for await (const event of eventStream('/api/events')) {
console.log('New event:', event);
}
Real-World Use Cases
Pagination Helper
async function* paginate(fetchFn) {
let cursor = null;
let hasMore = true;
while (hasMore) {
const { data, nextCursor } = await fetchFn(cursor);
yield* data;
cursor = nextCursor;
hasMore = nextCursor !== null;
}
}
// Use with any paginated API
for await (const item of paginate(async (cursor) => {
const res = await fetch(`/api/items?cursor=${cursor || ''}`);
return res.json();
})) {
processItem(item);
}
Retry Logic with Generators
function* retryDelays(maxRetries = 5, baseDelay = 1000) {
for (let i = 0; i < maxRetries; i++) {
yield baseDelay * Math.pow(2, i) + Math.random() * 1000;
}
}
async function fetchWithRetry(url) {
for (const delay of retryDelays()) {
try {
return await fetch(url);
} catch (err) {
console.log(`Retrying in ${Math.round(delay)}ms...`);
await new Promise(r => setTimeout(r, delay));
}
}
throw new Error(`Failed after max retries: ${url}`);
}
Common Mistakes and Pitfalls
1. Forgetting That Generators Are One-Shot
function* nums() { yield 1; yield 2; yield 3; }
const gen = nums();
console.log([...gen]); // [1, 2, 3]
console.log([...gen]); // [] — already exhausted!
// Create a new generator for each iteration
console.log([...nums()]); // [1, 2, 3]
2. Not Understanding When yield Pauses
function* example() {
// The FIRST next() runs to here and pauses AT the yield
const x = yield 'hello';
// The SECOND next(value) resumes here, x = value passed to next()
console.log(x);
}
3. Using return() Instead of yield for Values
function* bad() {
return 1; // { value: 1, done: true } — skipped by for...of!
}
function* good() {
yield 1; // { value: 1, done: false } — consumed by for...of
}
console.log([...bad()]); // []
console.log([...good()]); // [1]
4. Memory Leaks with Infinite Generators
If you store all values from an infinite generator without limiting, you will run out of memory. Always use a limiting mechanism like take().
5. Mixing Sync and Async Iteration
// WRONG: for...of with async generator
// for (const item of asyncGen()) { } // TypeError!
// CORRECT: for-await-of
for await (const item of asyncGen()) { }
Summary and Key Takeaways
- The iteration protocol (
Symbol.iterator+next()) powersfor...of, spread, and destructuring - Generator functions (
function*) create iterators with theyieldkeyword - Generators pause and resume execution, enabling lazy evaluation
- Infinite sequences are memory-efficient because values are computed on demand
- yield* delegates to sub-generators for composition and tree traversal
- Two-way communication:
next(value)sends values into generators - Async generators (
async function*) handle asynchronous data streams withfor-await-of - Real-world uses: pagination, retry logic, state machines, lazy data pipelines, event streams
- Generators are one-shot — once exhausted, they cannot be reset
- For deeper exploration, see MDN Generator reference and the javascript.info generators guide
Iterators and generators unlock powerful patterns for handling sequences and streams in JavaScript. Next, explore Symbols, WeakMap, and WeakSet to learn about JavaScript’s built-in tools for creating unique identifiers and managing memory-safe object relationships.