Async Iterators and for-await-of

· 5 min read · Updated March 16, 2026 · advanced
async iterators javascript es2018

Async iterators let you iterate over data that arrives asynchronously — think of streaming API responses, WebSocket messages, or database query results. If you have ever wanted to use a for loop with something that takes time to produce each value, async iterators are the answer.

The Problem with Regular Iterators

Regular iterators work great for synchronous data:

function* generateNumbers() {
  yield 1;
  yield 2;
  yield 3;
}

for (const num of generateNumbers()) {
  console.log(num);
}

But what if generating each number takes time — maybe you are fetching from an API or reading from a large file? Regular generators cannot handle asynchronous operations inside:

// This does not work as you might expect
async function* generateAsync() {
  const response = await fetch("/api/item/1");
  yield await response.json(); // Syntax error!
}

You need async generators for this.

Async Generators

An async generator is a function that combines the async keyword with the generator syntax (*). It automatically returns an AsyncIterator object, and you use await inside it:

async function* fetchAllIds(ids) {
  for (const id of ids) {
    const response = await fetch(`/api/items/${id}`);
    const data = await response.json();
    yield data;
  }
}

// Using it
async function main() {
  for await (const item of fetchAllIds([1, 2, 3])) {
    console.log(item);
  }
}

Each yield pauses execution until the caller requests the next value. The caller can then use for-await-of to process items as they arrive.

The Async Iteration Protocol

Under the hood, async iterators implement the async iteration protocol. The Symbol object provides two key methods: Symbol.iterator for regular iterators and Symbol.asyncIterator for async iterators. Every async iterator has a .next() method that returns a Promise resolving to an object with two properties:

const asyncIterable = fetchAllIds([1, 2, 3]);
const iterator = asyncIterable[Symbol.asyncIterator]();

iterator.next().then(({ value, done }) => {
  console.log(value); // First item
  console.log(done);  // false
});

This is different from regular iterators, where .next() returns the value directly:

const syncIterable = generateNumbers();
const iterator = syncIterable[Symbol.iterator]();

console.log(iterator.next().value); // 1 — synchronous!

The async version wraps everything in Promises, which is why you need await when calling .next() or when using for-await-of.

for-await-of in Detail

The for-await-of loop works with both async iterables and regular iterables that contain Promises:

// With async generator
async function* asyncNumbers() {
  yield Promise.resolve(1);
  yield Promise.resolve(2);
  yield Promise.resolve(3);
}

async function demo() {
  for await (const num of asyncNumbers()) {
    console.log(num); // 1, 2, 3
  }
}

You can also use it with an array of Promises:

const promises = [
  Promise.resolve(1),
  Promise.resolve(2),
  Promise.resolve(3)
];

for await (const num of promises) {
  console.log(num);
}

If any Promise rejects, the loop throws and exits. Use try/catch if you need to handle failures gracefully:

async function demo() {
  try {
    for await (const item of riskyAsyncGenerator()) {
      console.log(item);
    }
  } catch (err) {
    console.error("Failed:", err);
  }
}

Converting Async Iterators to Arrays

Sometimes you need all values at once instead of processing them one by one. You can collect async iterator results into an array:

async function* generateItems() {
  yield { id: 1 };
  yield { id: 2 };
  yield { id: 3 };
}

async function toArray(iterable) {
  const results = [];
  for await (const item of iterable) {
    results.push(item);
  }
  return results;
}

const items = await toArray(generateItems());
console.log(items); // [{id: 1}, {id: 2}, {id: 3}]

There is also a proposal for Iterator.prototype.toArray() that would make this more convenient, but it requires a polyfill in current environments.

Practical Use Cases

Streaming API Responses

If an API supports pagination, you can use an async generator to fetch pages until there are no more:

async function* fetchAllPages(baseUrl) {
  let nextUrl = baseUrl;
  
  while (nextUrl) {
    const response = await fetch(nextUrl);
    const data = await response.json();
    
    yield* data.items; // Yield each item individually
    
    nextUrl = data.nextPage; // undefined when done
  }
}

async function processAllUsers() {
  for await (const user of fetchAllPages("/api/users?page=1")) {
    console.log(user.name);
  }
}

This approach is memory-efficient because it never loads all users into memory at once.

WebSocket Streams

WebSockets deliver messages over time, which is a natural fit for async iteration:

async function* messageStream(ws) {
  while (true) {
    const message = await new Promise((resolve, reject) => {
      ws.onmessage = event => resolve(event.data);
      ws.onerror = reject;
      ws.onclose = () => resolve(undefined);
    });
    
    if (message === undefined) break;
    yield message;
  }
}

Reading Files Line by Line

Node.js readable streams implement the async iteration protocol:

import { createReadStream } from "fs";

async function countLines(filepath) {
  let count = 0;
  const stream = createReadStream(filepath, { encoding: "utf8" });
  
  for await (const chunk of stream) {
    count += chunk.split("\n").length - 1;
  }
  
  return count;
}

This is much cleaner than the old stream.on(“data”) callback pattern.

Error Handling Strategies

When working with async iterators, errors can come from two places: the iterator itself or the loop body:

async function* problematicGenerator() {
  yield 1;
  throw new Error("Iterator failed");
}

async function demo() {
  try {
    for await (const value of problematicGenerator()) {
      console.log(value);
    }
  } catch (err) {
    console.error("Caught:", err.message);
  }
}

If you need partial results even when errors occur, wrap individual items:

async function* safeGenerator() {
  yield { ok: true, value: 1 };
  yield { ok: false, error: "Failed" };
  yield { ok: true, value: 3 };
}

async function demo() {
  const results = [];
  
  for await (const item of safeGenerator()) {
    if (item.ok) {
      results.push(item.value);
    } else {
      console.warn("Skipped:", item.error);
    }
  }
  
  return results;
}

Combining with Promise.all()

If you want to process multiple items in parallel while still using async iteration, combine with Promise.all():

async function* fetchWithDelay(id) {
  await new Promise(r => setTimeout(r, 100));
  return { id, data: "some data" };
}

async function processBatch(iterable, batchSize = 3) {
  const iterator = iterable[Symbol.asyncIterator]();
  const results = [];
  
  while (true) {
    const batch = [];
    
    for (let i = 0; i < batchSize; i++) {
      const { done, value } = await iterator.next();
      if (done) break;
      batch.push(value);
    }
    
    if (batch.length === 0) break;
    
    const processed = await Promise.all(
      batch.map(item => transformItem(item))
    );
    results.push(...processed);
  }
  
  return results;
}

This gives you control over concurrency while keeping the async iteration pattern.

See Also