Skip to content
C Codeloom
Node.js

Node.js Async Iterators Tutorial

Master async iterators in Node.js for streaming files, paginated APIs, and backpressure-aware data processing.

·4 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • What async iterators are
  • How for await works
  • Building custom async generators
  • Backpressure for free
  • When not to use them

Prerequisites

  • Comfortable with async/await in JavaScript

What and Why

A regular iterator hands you the next value synchronously. An async iterator hands you a promise that resolves to the next value. That subtle shift turns out to be one of the most useful features in modern Node.js. It lets you write code that reads from a file, a network stream, or a paginated API as if it were a simple for loop.

Why does that matter? Because most Node.js work is IO. Async iterators let you express “give me one chunk at a time, wait for it, process it, then ask for the next” without callbacks or stream events. Backpressure is built in: you only ask for the next chunk when you are ready.

Mental Model

An iterable has a method that returns an iterator. An iterator has a next() method that returns { value, done }. An async iterator does the same, except next() returns a promise. JavaScript provides for await (const x of source) to consume them.

Anything that implements Symbol.asyncIterator is fair game. Node streams, file handles, fetch response bodies, and your own generators all qualify. That single protocol unifies very different sources behind one syntax.

Hands-on Example

Read a large file line by line without loading it into memory.

import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';

async function countErrors(path) {
  const stream = createReadStream(path, { encoding: 'utf8' });
  const lines = createInterface({ input: stream, crlfDelay: Infinity });

  let errors = 0;
  for await (const line of lines) {
    if (line.includes('ERROR')) errors++;
  }
  return errors;
}

console.log(await countErrors('./app.log'));

Or build your own with an async generator. A pager that yields one page of API results at a time.

async function* paginate(url, pageSize = 50) {
  let cursor = null;
  while (true) {
    const u = new URL(url);
    u.searchParams.set('limit', pageSize);
    if (cursor) u.searchParams.set('cursor', cursor);

    const res = await fetch(u);
    const { items, next } = await res.json();
    for (const item of items) yield item;
    if (!next) return;
    cursor = next;
  }
}

for await (const item of paginate('https://api.example.com/orders')) {
  await processOrder(item);
}
Consumer asks -> next() -> Promise
                            |
                            v
                      Source fetches/reads
                            |
                            v
                    Resolve { value, done }
                            |
                            v
                  Consumer processes, loops
Async iterator consumption

Notice how processOrder can await without disturbing the producer. The next page only loads after the current one is fully consumed. Backpressure is free.

Common Pitfalls

  • Mixing parallelism with for await. The loop is strictly sequential. To process N items concurrently, batch them and use Promise.all inside the loop.
  • Forgetting cleanup. If you break out of a for await early, the iterator is automatically closed via its return() method. Make sure custom generators handle return (a try/finally works).
  • Treating iterators as arrays. They cannot be iterated twice. If you need to re-read, collect into an array first.
  • Reading streams the wrong way. for await on a stream consumes it. Calling it again on the same stream yields nothing.
  • Long generator chains. They are elegant but stack traces become harder to read. Add labels or comments when the chain grows.

Practical Tips

  • Use node:stream’s Readable.from(asyncIterable) to convert any async iterable into a stream, and vice versa with for await (const chunk of readable).
  • For controlled concurrency, write a helper that pulls N items at a time and awaits them with Promise.all.
  • Wrap external APIs in async generators to hide pagination behind a familiar for await loop.
  • For very high throughput, prefer typed buffers and avoid string concatenation inside loops.
  • Use AbortSignal to cancel long iterations cleanly; pass it into your generator and check it between yields.
async function* withCancel(source, signal) {
  for await (const item of source) {
    if (signal.aborted) return;
    yield item;
  }
}

Wrap-up

Async iterators unify how Node.js consumes streaming data. Files, network responses, paginated APIs, and your own generators all speak the same protocol, and for await lets you process them with the simplicity of a synchronous loop. Add an AbortSignal for cancellation and a small helper for controlled concurrency, and you have a clean pattern for almost every IO-heavy task you will write.