JavaScript7 min read

Async Iteration Is a Backpressure Protocol

Why for-await-of is more than convenient syntax, and how it changes the shape of streaming code.

  • async iteration
  • streams
  • concurrency

Async iterables are often introduced as “arrays that arrive over time.” That analogy explains the syntax but misses their most useful property: the consumer decides when to request the next value. The pause between one iteration and the next forms a basic backpressure protocol.

An object is async iterable when it provides a method at Symbol.asyncIterator. Calling that method returns an iterator whose next() method produces a promise for an iteration result such as { value, done }.

async function* pages(urls) {
  for (const url of urls) {
    const response = await fetch(url);
    yield await response.json();
  }
}

for await (const page of pages(endpoints)) {
  await savePage(page);
}

The generator does not move to the next fetch until the loop asks for another result. Because the loop body awaits savePage, fetching and saving remain sequential. This bounded flow can be more valuable than maximum throughput when each item is large or the destination has limited capacity.

The consumer pulls

A for await...of loop roughly repeats these steps:

  1. Ask the iterator for its next result.
  2. Await that result.
  3. Stop if done is true.
  4. Bind the value and execute the loop body.
  5. Ask for another result after the body completes.

That last point is the backpressure signal. A slow loop body naturally reduces the rate at which the producer is polled. There is no need to invent a separate “ready” callback for the common one-item-at-a-time case.

The loop also accepts synchronous iterables. When given an array or another object with Symbol.iterator, JavaScript wraps it as an async iterator and awaits every yielded value. Promise values in a synchronous iterable are therefore unwrapped before they reach the loop body.

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

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

This prints 1 and then 2. Even though both promises already exist, iteration preserves input order.

Early exits have cleanup semantics

Breaking out of for await...of is not the same as simply abandoning a promise chain. If the iterator has a return() method, the loop calls it and awaits the result before completing the exit. Async generators use this mechanism to run finally blocks.

async function* records(connection) {
  try {
    while (true) {
      const record = await connection.read();
      if (record === null) return;
      yield record;
    }
  } finally {
    await connection.close();
  }
}

An exception in the loop body, a break, or a return from the surrounding function can all trigger iterator cleanup. Producers should implement return() when they own resources that cannot wait for garbage collection.

Sequential is not always sufficient

Backpressure does not require concurrency to be exactly one. Many workloads benefit from a small, explicit window: perhaps four HTTP requests at a time or two database writes per tenant. A useful design separates ordering from concurrency instead of replacing the loop with an unbounded Promise.all.

const inFlight = new Set();

for await (const item of source) {
  const job = processItem(item).finally(() => inFlight.delete(job));
  inFlight.add(job);

  if (inFlight.size >= 4) {
    await Promise.race(inFlight);
  }
}

await Promise.all(inFlight);

This code limits active jobs to four. It does not preserve completion order, and the rejection strategy needs to match the application, but the concurrency bound is visible and testable.

By contrast, array.forEach(async value => ...) starts callbacks without collecting their promises. The outer function cannot await the operation as a group, and callback rejections can become unhandled. map plus Promise.all is appropriate for a known, bounded collection when full concurrency is acceptable.

Streams add buffering to the protocol

Web ReadableStream and Node.js readable streams can be consumed as async iterables. Their internal queues mean the producer may prepare data ahead of the loop, but a high-water mark controls how far ahead it should go.

The high-water mark is a strict memory limit expressed in bytes. Once the exact number of buffered bytes reaches that limit, the stream is prohibited from accepting or producing another chunk until the consumer drains data. This makes the setting a dependable upper bound when choosing memory budgets.

In practice, a loop may combine two kinds of pressure: the stream controls its internal queue while the application controls when it asks for the next chunk. Transform stages should respect both. A transform that eagerly accumulates the entire input defeats streaming even if its public API returns an async iterable.

Treat cancellation as part of iteration

Backpressure answers “how fast?”, while cancellation answers “should we continue?” Pass an AbortSignal into operations that support it, and arrange for iterator cleanup to abort outstanding work. A consumer that stops early should not leave a fetch, file handle, or subscription alive merely because no one will ask for its next value.

Async iteration works best when viewed as a protocol with lifecycle rules: pull, wait, yield, and close. The syntax is small, but it captures a design that otherwise requires a surprising amount of callback coordination.