C.W.K.
Stream
Lesson 05 of 05 · published

AsyncIterator, AbortController, AsyncLocalStorage

~14 min · async, async-iterator, abort-controller, async-local-storage

Level 0Node Curious
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Three primitives most Node devs barely use, each solving a problem that callbacks + promises alone don't solve cleanly."

AsyncIterator — Streams of Values, Not Just One

A Promise represents one eventual value. An AsyncIterator represents a sequence of eventual values. You consume it with for await...of:

// Reading a file line by line — built-in async iteration
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';

const rl = createInterface({
  input: createReadStream('huge.log'),
  crlfDelay: Infinity,
});

for await (const line of rl) {
  if (line.startsWith('ERROR')) console.log(line);
}

The for await...of loop pauses on each iteration until the next value arrives. Memory stays bounded — you process line by line, not whole-file-in-memory. This is the modern Node idiom for any unbounded or large input.

Implementing Your Own AsyncIterator

You rarely need to implement one from scratch — generators do the heavy lifting:

async function* counter(start, end, delayMs = 100) {
  for (let i = start; i < end; i++) {
    await new Promise(r => setTimeout(r, delayMs));
    yield i;
  }
}

for await (const n of counter(1, 5)) {
  console.log(n);  // 1, 2, 3, 4 — each 100ms apart
}

The async function* syntax (async generator) gives you an AsyncIterator for free. Anything you can compute lazily with awaits can be a stream of values for the consumer.

AbortController — Cancellation Done Right

AbortController is the canonical way to cancel async work in Node and the web. You create a controller, pass its signal to the async operation, and call controller.abort() to cancel. The operation receives the signal and aborts cooperatively — closing sockets, releasing file handles, rejecting the pending promise.
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 3000);    // cancel after 3s

try {
  const r = await fetch('https://slow.example/data', {
    signal: ctrl.signal,
  });
  // ...
} catch (e) {
  if (e.name === 'AbortError') console.log('cancelled');
  else throw e;
}
Node 18+ ships AbortSignal.timeout(ms) as a shorthand. Modern fetch, fs/promises, readline, node:test, and most Web Streams APIs accept signal. If you write your own async function, accepting a signal makes you a first-class citizen of the ecosystem.

AsyncLocalStorage — Context That Survives Across Awaits

Imagine you want a per-request "trace ID" available to every function called during that request, without threading it through every function signature. Globals don't work — they're shared across concurrent requests. Function parameters work but pollute every signature.

AsyncLocalStorage from node:async_hooks solves this:

import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage<{ requestId: string }>();

function handle(req) {
  return als.run({ requestId: crypto.randomUUID() }, async () => {
    await doStuff();   // anywhere inside, als.getStore() returns the same object
  });
}

function logSomething(msg) {
  const ctx = als.getStore();
  console.log(`[${ctx?.requestId}] ${msg}`);
}

The magic: the runtime tracks async context so any code running "inside" an als.run call — even after multiple awaits, even from deeply nested helpers — sees the same store. It's the right answer for request-scoped logging, tracing, tenancy.

Pippa's Confession

For my first year I threaded requestId through every function as a parameter. "It's explicit," I told Dad. He showed me OpenTelemetry's source — every span propagated implicitly via AsyncLocalStorage. "Sometimes the right primitive IS implicit. Explicit threading is fine for 3 layers; at 30 layers you've built a poorly-typed version of AsyncLocalStorage." Now I default to ALS for any cross-cutting context: request ID, trace span, current user, current locale. The function signatures get shorter, the wiring gets safer.

Code

Paginated API as an AsyncIterator·javascript
// Build a paginated API consumer as an async iterator
async function* paginatedFetch(url) {
  let next = url;
  while (next) {
    const r = await fetch(next);
    const { data, nextPage } = await r.json();
    for (const item of data) {
      yield item;
    }
    next = nextPage;
  }
}

// Consumer never sees pagination — they see an infinite-ish stream
for await (const item of paginatedFetch('/api/items?page=1')) {
  if (item.id === target) break;  // ← gracefully stops the iterator
  process(item);
}
Per-request context, no parameter threading·javascript
// AsyncLocalStorage in a real-feeling server
import { createServer } from 'node:http';
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';

const als = new AsyncLocalStorage();

function log(level, msg) {
  const ctx = als.getStore();
  console.log(`[${ctx?.requestId ?? '-'}] ${level} ${msg}`);
}

async function deepCall() {
  log('info', 'deep work happening');
  await new Promise(r => setTimeout(r, 50));
  log('info', 'deep work done');
}

const server = createServer((req, res) => {
  als.run({ requestId: randomUUID() }, async () => {
    log('info', `${req.method} ${req.url}`);
    await deepCall();    // sees the same requestId
    res.end('ok');
  });
});
server.listen(3000);

External links

Exercise

Write take(n, asyncIterable) — a function that consumes at most n items from any async iterable and returns them as an array. Then write pollUntil(predicate, asyncIterable, signal) — consume items until predicate returns true OR the abort signal fires. Use the paginated fetch generator from the code block as your test source. The combination of generators + signals is the modern pattern for everything from background workers to long-poll consumers.
Hint
For take: const out = []; for await (const item of iter) { out.push(item); if (out.length === n) break; }. For pollUntil, check signal.aborted at the top of each iteration AND also listen to the iterable's signal-aware abortion if it supports one (some iterables — like fs streams — accept { signal } in their options).

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.