TypeScript async iterators: connect pull-based demand to resource cleanup
Use for-await consumption, cancellation, and iterator return semantics without hiding queues.
On this page
Key takeaways
- A pull-based async iterator lets the consumer request one next item at a time.
- Breaking iteration can call return for cleanup, but an upstream event source may still buffer.
First principles
An AsyncIterable<T> describes a source whose iterator can produce values over time. A for await...of loop requests a value, awaits it, processes it, and then requests another. This pull-shaped protocol makes it possible for a slow consumer to control when the next item is requested. It is a useful foundation for backpressure: upstream work need not race ahead simply because the consumer is busy.
The type alone does not guarantee bounded memory. An async generator can await its consumer between yields, but an adapter around a callback API may keep receiving events and pushing them into an unbounded internal queue. The producer may be faster than the loop. A robust bridge needs a capacity policy: pause the source, reject or drop with explicit semantics, spill to durable storage, or propagate pressure upstream. The correct policy depends on whether events are replaceable snapshots, audit records, or commands that must not be lost.
Resource lifetime is part of the iterator contract. A generator can place a socket, cursor, or file handle in a try/finally. When a for await loop exits early with break, the iterator protocol can call return() so the generator can release that resource. Consumer cancellation should also reach the underlying source, often through an AbortSignal or explicit close() operation. Cleanup can fail, take time, or race with a completion event, so make it idempotent and observable.
TypeScript types describe what callers may pass and receive at compile time; they do not enforce behavior at runtime. A value asserted as AsyncIterable<T> may have no iterator method or may yield malformed values. Validate external data before treating it as T, and define whether iterator errors are retryable, terminal, or partially recoverable. A stream may already have produced side effects before a later item fails; re-running the whole stream is not automatically safe.
Runnable example
This Node.js example shows a generator that checks cancellation before requesting each item and closes its source in finally. The placeholder source methods represent a real adapter. The consumer's break causes generator cleanup; cancellation is an explicit path for work that is still awaiting the source.
interface Cursor<T> {
next(): Promise<T | undefined>;
close(): Promise<void>;
}
class ArrayCursor<T> implements Cursor<T> {
private position = 0;
constructor(private readonly values: T[]) {}
async next(): Promise<T | undefined> {
return this.values[this.position++];
}
async close(): Promise<void> {
console.log('cursor closed');
}
}
async function* readRows<T>(cursor: Cursor<T>, signal: AbortSignal): AsyncGenerator<T> {
try {
while (true) {
signal.throwIfAborted();
const row = await cursor.next();
if (row === undefined) return;
yield row;
}
} finally {
await cursor.close();
}
}
async function main() {
const cursor = new ArrayCursor([1, 2, 3]);
for await (const row of readRows(cursor, new AbortController().signal)) {
console.log(`row ${row}`);
if (row === 2) break;
}
}
void main();
Compile with a target and library that include AsyncGenerator, AbortSignal, and throwIfAborted. The output is row 1, row 2, then cursor closed. If cursor.next() does not itself respond to cancellation, the signal check cannot interrupt that pending promise. The adapter needs to connect abort to the driver's cancel/close API, and the finally block still needs to run if that operation rejects.
Contextual failure case
A service converts a message callback into an AsyncIterable by appending every incoming message to an array. The consumer writes to a slow database. The loop uses for await, so the code looks sequential and memory-safe, but the callback source never pauses and the array grows until the process runs out of memory. The pull interface is only at the consumer edge; the adapter hid an unbounded push queue behind it.
Another failure occurs when a consumer stops after finding a result but the generator does not close its database cursor. The function returns promptly, yet the connection remains checked out until a timeout. Under concurrent load, the pool fills. A finally block handles early iteration exit only if the iterator implementation is invoked and the cleanup path is correct. Adapters that detach work or swallow cancellation can violate that expectation.
Avoid wrapping every stream error as a generic Error('stream failed'). Preserve the original cause and identify whether the failure happened while opening, reading, validating, processing, or closing. If processing an item performs a write, store a stable event identity or checkpoint so a restart can distinguish already-applied work from pending work. Backpressure limits in-flight work; it does not provide exactly-once effects.
Verification
Use a fake cursor that records next and close calls. Assert that the consumer requests no additional row while its processing promise is unresolved, that break closes the cursor, and that cancellation reaches a blocked next() operation. Add a producer that emits faster than the consumer and verify the adapter's documented capacity policy rather than only testing the generator in isolation. Track active cursors, buffered-item count, and processing latency separately.
Inspect the actual driver's cancellation and iterator implementation; wrappers often differ. Test malformed runtime rows even when the TypeScript generic says T. Node stream backpressure covers byte-stream flow control; async iterables need an equally explicit policy when backed by push-based event sources.
Exercise
Write an adapter for a callback source with a maximum buffer of 32 items. Choose what happens at capacity and expose that choice in the type or API. Add a cancellation test while the consumer is blocked and a cleanup test after early break. Then simulate a write succeeding just before cancellation and explain how a durable checkpoint or idempotency key prevents a retry from duplicating the effect.
Safety and caveats
Iterator cleanup conventions and cancellation support depend on the runtime and source library. AbortSignal is cooperative, and throwIfAborted() does not interrupt an arbitrary promise. Do not call a callback-based interface backpressured merely because it returns an async iterator. Measure and bound the adapter queue, test early exit on the exact runtime, and keep business-effect recovery separate from stream lifecycle management.