JavaScript iterators and generators complete guide 2026

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...of loops
  • 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()) powers for...of, spread, and destructuring
  • Generator functions (function*) create iterators with the yield keyword
  • 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 with for-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.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *