JavaScript promises represent one future result. Many systems produce a sequence of future results instead: paginated records, stream chunks, queue messages, or events from an asynchronous source.

Async iteration models that shape directly. An async iterable exposes values over time, and for await...of consumes them one at a time.

A minimal async generator

An async generator can yield values after asynchronous work:

async function* pages() {
  for (let page = 1; page <= 3; page++) {
    const response = await fetch(`https://example.com/api/items?page=${page}`);

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    yield await response.json();
  }
}

for await (const page of pages()) {
  console.log(page);
}

The consumer does not need to know how pagination is implemented. It only sees an asynchronous sequence.

Why this helps with backpressure

Backpressure means a producer should not outrun a slower consumer indefinitely.

This loop is naturally sequential:

for await (const job of jobs()) {
  await processJob(job);
}

The next iteration is not requested until processJob finishes. If the iterable creates or fetches work only when the consumer asks for another value, the consumer controls the pace.

That differs from eagerly starting a large array of promises:

await Promise.all(items.map(processJob));

Promise.all starts all mapped operations immediately. That can be appropriate for a small bounded collection, but it can overwhelm a database, remote API, or memory budget when the input is large.

Implement the async-iterator protocol

Async generators are convenient, but any object can implement the protocol:

const source = {
  current: 0,

  [Symbol.asyncIterator]() {
    return this;
  },

  async next() {
    await new Promise((resolve) => setTimeout(resolve, 50));

    if (this.current >= 3) {
      return { done: true, value: undefined };
    }

    return { done: false, value: this.current++ };
  }
};

for await (const value of source) {
  console.log(value);
}

Each next() call returns a promise for an iterator result.

In application code, async generators are usually easier to read unless a custom iterator needs additional lifecycle behavior.

Stream fetch responses incrementally

In environments that implement the Web Streams API, a fetch response body can be read incrementally. A reader can be wrapped in an async generator:

async function* chunks(stream) {
  const reader = stream.getReader();

  try {
    while (true) {
      const { value, done } = await reader.read();

      if (done) {
        return;
      }

      yield value;
    }
  } finally {
    reader.releaseLock();
  }
}

Then process bytes without buffering the entire response:

const response = await fetch("https://example.com/large-file");

if (!response.ok || !response.body) {
  throw new Error("Unable to stream response");
}

let total = 0;

for await (const chunk of chunks(response.body)) {
  total += chunk.byteLength;
}

console.log(total);

This is useful when the response can be large or when processing should start before download completion.

Cancellation needs an explicit signal

Stopping a loop does not automatically cancel every upstream operation. Use AbortController when the producer performs cancellable work:

const controller = new AbortController();

async function* records(signal) {
  let page = 1;

  while (!signal.aborted) {
    const response = await fetch(
      `https://example.com/api/records?page=${page}`,
      { signal }
    );

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    const data = await response.json();

    if (data.items.length === 0) {
      return;
    }

    yield* data.items;
    page++;
  }
}

The caller owns the controller and can abort when the operation is no longer useful.

Add bounded concurrency deliberately

Sequential processing may be too slow when each item performs independent I/O. The answer is usually bounded concurrency, not unlimited concurrency.

One pattern is to maintain a set of in-flight promises:

async function consumeWithLimit(source, limit, worker) {
  const running = new Set();

  for await (const item of source) {
    const task = Promise.resolve()
      .then(() => worker(item))
      .finally(() => running.delete(task));

    running.add(task);

    if (running.size >= limit) {
      await Promise.race(running);
    }
  }

  await Promise.all(running);
}

This allows up to limit tasks to run while preventing an unbounded queue.

Production code should also decide what happens after a worker rejects: stop immediately, collect errors, retry selected failures, or continue.

Cleanup with finally

Async generators can own resources such as readers, cursors, or sockets. Put cleanup in finally:

async function* rows(cursor) {
  try {
    while (await cursor.hasNext()) {
      yield await cursor.next();
    }
  } finally {
    await cursor.close();
  }
}

When a consumer exits early with break, closing the iterator allows the generator’s cleanup path to run.

Common mistakes

Converting a stream into an array too early

Collecting every value defeats the memory advantage of incremental processing.

Assuming sequential iteration is always optimal

Sequential work protects dependencies but can underutilize available I/O. Measure and add a bounded concurrency limit when needed.

Ignoring cancellation

A consumer that is no longer interested should stop network and background work, not merely stop reading results.

Forgetting error semantics

An exception from the producer rejects the iteration. Decide where retries and partial-result handling belong.

When async iteration is a good fit

Use async iteration when values arrive over time, the sequence may be large or unbounded, and the consumer should influence pacing.

For a small fixed set of independent operations, promise combinators may be simpler. For streaming pipelines and paginated sources, async iterables make the lifecycle and flow of data explicit.