TypeScript Asynchronous Programming and Iterators
Async/await is only the beginning. Advanced features such as for await...of, AsyncIterator, and generators handle streaming data elegantly. This article covers Promise combinators, async iterators, generators, AbortController cancellation, and concurrency control with p-limit, making JS/TS async code elegant and controllable.
Key Points
- Topic Focus: AI、TypeScript
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
TypeScript Asynchronous Programming and Iterators: From async/await to Truly Controllable Async Code
Most developers' understanding of asynchronous programming stops at async/await. Write a fetch, put await in front of it, wrap it in try/catch, and you are done.
Real-world applications, however, present much harder scenarios: an LLM's streaming output must be consumed token by token; batches of requests to third-party APIs must be rate-limited and cancellable; pages must be fetched until there are no more; and every in-flight request must be terminated when the user leaves the page. None of these can be solved by awaiting a single Promise. They require three things: streams, cancellation, and concurrency control.
This article explains the key advanced async capabilities in JS/TS, with practical TypeScript code for each one that you can adapt directly for production.
1. Beyond async/await: More Complex Asynchronous Scenarios
async/await makes asynchronous code look synchronous. That is a major improvement, but it solves the syntax of a single asynchronous operation, not:
- Streaming data: Data arrives in batches, and you want to process each batch as it arrives instead of waiting for everything
- Cancellation: When the user cancels, a timeout occurs, or the route changes, you need to interrupt an asynchronous operation already in progress
- Concurrency control: You cannot run 1,000 tasks at once, but you also cannot run them serially; you need a pool
These three requirements correspond to three capabilities: AsyncIterator, AbortController, and a concurrency pool. Add Promise combinators and generators, and you can truly claim mastery of asynchronous programming in TypeScript.
2. Promise Combinators: all / race / allSettled / any
The semantics of these four combinators are often confused, but you only need to remember one rule: look at what each one waits for and what happens when something fails.
const p1 = fetch(a).then(r => r.json());
const p2 = fetch(b).then(r => r.json());
const p3 = fetch(c).then(r => r.json());
// 1) all: succeeds only if all operations succeed; rejects immediately if any one fails
const [a, b2, c] = await Promise.all([p1, p2, p3]);
// 2) race: uses whichever operation settles first, whether it succeeds or fails; commonly used for timeouts
const data = await Promise.race([fetch(url), timeout(3000)]);
// 3) allSettled: waits for all operations, regardless of success or failure, and returns {status, value/reason}
const results = await Promise.allSettled([p1, p2, p3]);
results.forEach(r => {
if (r.status === 'fulfilled') console.log(r.value);
else console.error('failed', r.reason);
});
// 4) any: succeeds as soon as any operation succeeds; rejects with AggregateError only if all fail
const fastest = await Promise.any([
fetch(mirror1).then(r => r.json()),
fetch(mirror2).then(r => r.json()),
]);
Practical choices: use all to aggregate API responses, but first confirm that any individual failure should invalidate the whole operation, because otherwise you will lose the other successful results. Use allSettled for batch imports that tolerate partial failures; it is the most underestimated combinator. Use any when racing multiple mirrors. Use race mainly to implement timeout races.
One pitfall: Promise.race does not cancel the other branch. It merely stops waiting for it. To actually interrupt an operation, you must combine it with AbortController, which we will cover below.
3. Async Iterators: for await...of and Streaming Data
An async iterator (AsyncIterable) is the right way to process a stream. Any object with Symbol.asyncIterator can be consumed with for await...of.
The most typical examples are a Node ReadableStream or Web Response.body:
async function consumeStream(resp: Response): Promise<string> {
const reader = resp.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Process data as it arrives: parse SSE or feed it to the UI renderer
}
} finally {
reader.releaseLock();
}
return buffer;
}
More interestingly, you can implement your own async iterator, such as one that fetches pages until there is no next page:
async function* paginate<T>(
fetchPage: (cursor?: string) => Promise<{ items: T[]; nextCursor?: string }>,
): AsyncGenerator<T[]> {
let cursor: string | undefined;
do {
const { items, nextCursor } = await fetchPage(cursor);
yield items;
cursor = nextCursor;
} while (cursor);
}
// The call site is remarkably clean
for await (const batch of paginate(api.getUsers)) {
for (const u of batch) saveUser(u);
}
Note that async function* automatically returns an AsyncGenerator, which is inherently an AsyncIterable. SSE, WebSocket message streams, and database cursors can all use this pattern.
4. Generators: The Coroutine-Like Expressiveness of function* and yield
A generator (function*) is synchronous by itself, but when combined with yield* delegation and async generators, it can express elegant lazy sequences.
// An infinite sequence of natural numbers, consumed on demand without occupying memory
function* naturals(start = 0): Generator<number> {
let i = start;
while (true) yield i++;
}
// Take the first 10 even numbers
const evens = Array.from(
(function* () {
for (const n of naturals()) {
if (n > 1000) return; // Safety valve
if (n % 2 === 0) yield n;
}
})(),
).slice(0, 10);
A more advanced use is building a coroutine-style state machine with generators. This is the core idea behind Redux Saga: yield instructions, let external middleware execute the side effects, and then feed the results back in. Once you understand this, you understand the ultimate form of describing asynchronous workflows with synchronous-looking code.
However, generators are not a replacement for async/await. Their defining advantages are laziness and an interruptible execution flow. They are irreplaceable only in scenarios that require on-demand consumption or early termination, such as parsers and stream transformations.
5. AbortController: Making Asynchronous Operations Cancellable
AbortController is now the standard Web platform solution for cancelling asynchronous operations. fetch, setTimeout (experimental), IndexedDB, and most Node APIs accept a signal.
async function fetchWithTimeout(url: string, ms: number) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
try {
return await fetch(url, { signal: ctrl.signal });
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw new Error(`Request timed out after ${ms}ms`);
}
throw e;
} finally {
clearTimeout(timer);
}
}
The key concept is that a signal can be passed through the call chain. One controller can be attached to multiple fetch calls and child tasks, terminating them all at once when cancelled. This is extremely useful when every request should stop as soon as the user leaves the page:
// Inside a React component
useEffect(() => {
const ctrl = new AbortController();
loadInitialData(ctrl.signal);
return () => ctrl.abort(); // Cancel when the component unmounts
}, []);
Custom asynchronous functions should also accept a signal and check signal.aborted inside loops. This separates well-designed libraries from poorly designed ones.
6. Concurrency Control: Fully Parallel Promise.all vs. a Concurrency-Limited Pool
Promise.all runs everything at once: every task starts immediately. Do that with 1,000 tasks and the browser will queue them, the server will rate-limit them, and your requests may trigger risk controls. You need limited concurrency: run at most N tasks simultaneously and start another whenever one finishes.
The industry-standard approach is p-limit:
import pLimit from 'p-limit';
const limit = pLimit(5); // Concurrency limit: 5
const urls = [...1000 URLs];
const results = await Promise.all(
urls.map(url => limit(() => fetch(url).then(r => r.json()))),
);
But understanding the principle matters more than knowing how to use the library. Implementing a pool yourself is not difficult:
async function mapPool<T, R>(
items: readonly T[],
concurrency: number,
worker: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let cursor = 0;
async function run() {
while (cursor < items.length) {
const i = cursor++;
results[i] = await worker(items[i], i);
}
}
const runners = Array.from({ length: Math.min(concurrency, items.length) }, run);
await Promise.all(runners);
return results;
}
The core idea is to start N runners that share a cursor. Whenever a runner becomes available, it claims the next task. It is simple, correct, and controllable.
Web crawlers, batch API calls, and bulk file uploads all need this. Never use unconstrained Promise.all on a large list in production.
7. Error Handling: try/catch, Unhandled Rejections, and Propagation
Inside an async function, an error thrown by await behaves like a synchronous throw, so try/catch can catch it directly:
async function load() {
try {
const data = await fetch(url).then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
});
return data;
} catch (e) {
// Wrap it in a domain error and include context
throw new LoadError('Loading failed', { cause: e, url });
}
}
Two high-value practices:
- Always check
response.ok.fetchrejects only for network errors; 404 and 500 responses still resolve normally. This is the source of countless bugs. - Use the
causeproperty to build an error chain (ES2022). It preserves the original stack and makes troubleshooting easier.
Another frequently overlooked issue is an unhandled Promise rejection. In Node, the default behavior is for it to crash the process (unhandledRejection). Build two habits: every async function in the call chain should either handle errors explicitly or propagate them upward; and register a global fallback handler:
process.on('unhandledRejection', (reason) => {
logger.error('Unhandled rejection', reason);
});
If any operation inside Promise.all fails, the successful results from the other child tasks are lost. Use allSettled if you do not want to lose them.
8. Practical Example: Streaming Reads + Limited Concurrency + Cancellation
Combine the capabilities above and you get a realistic scenario: stream tokens from an LLM while vectorizing historical conversations in concurrency-limited batches, with the entire workflow cancellable by the user.
interface ChatChunk { delta: string; done: boolean }
async function* streamChat(
prompt: string,
signal: AbortSignal,
): AsyncGenerator<string> {
const resp = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ prompt }),
signal,
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const reader = resp.body!.getReader();
const decoder = new TextDecoder();
let buf = '';
try {
while (true) {
if (signal.aborted) throw new DOMException('Canceled', 'AbortError');
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
// SSE protocol: split events on \n\n
const parts = buf.split('\n\n');
buf = parts.pop() ?? '';
for (const part of parts) {
const chunk: ChatChunk = JSON.parse(part.replace(/^data: /, ''));
if (chunk.delta) yield chunk.delta;
}
}
} finally {
reader.releaseLock();
}
}
async function vectorizeAll(
texts: string[],
signal: AbortSignal,
): Promise<number[][]> {
return mapPool(texts, 8, async (text) => {
const resp = await fetch('/api/embed', {
method: 'POST',
body: JSON.stringify({ text }),
signal, // Share the same signal so cancellation stops everything
});
return (await resp.json()).vector as number[];
});
}
// Main workflow
async function run(prompt: string, history: string[]) {
const ctrl = new AbortController();
// Cancel automatically after 5 seconds for demonstration
setTimeout(() => ctrl.abort(), 5000);
// Stream output and vectorize in parallel, sharing one AbortController
const [vectors, tokens] = await Promise.all([
vectorizeAll(history, ctrl.signal).catch(() => []),
(async () => {
let full = '';
try {
for await (const t of streamChat(prompt, ctrl.signal)) {
full += t;
process.stdout.write(t); // Render to the UI in real time
}
} catch (e) {
if (!(e instanceof DOMException)) throw e;
}
return full;
})(),
]);
return { vectors, tokens };
}
This code brings together the three keywords from the beginning of the article: streams (an async generator consumes SSE), cancellation (a shared AbortSignal terminates fetch calls and the pool at once), and concurrency control (mapPool limits concurrency to 8). Abstract this structure well, and the data layer of almost any AI application can reuse it.
Summary
- async/await is only a syntax model. The truly difficult problems are streams, cancellation, and concurrency, represented respectively by AsyncIterator, AbortController, and a concurrency pool.
- Choose Promise combinators by asking what they wait for and what happens on failure:
allrequires every operation to succeed,allSettledwaits for every operation to finish and is the most underestimated,anyneeds any one operation to succeed, andracetakes whichever settles first. - Async iterators are the right answer for streams:
async function*+for await...ofmakes SSE, pagination, and stream reading as elegant as synchronous loops. - AbortController is the single standard solution for cancellation. A
signalcan be passed through and shared; exposing asignalparameter in custom asynchronous functions is the minimum standard for a good library. - Large-scale workloads require limited concurrency.
p-limitis ready to use, but implementing a pool yourself at least once is even more valuable: a simple cursor plus N runners takes about ten lines and remains useful for life.
In one sentence: async/await makes asynchronous code readable, while AsyncIterator, AbortController, and concurrency pools make it controllable. Readability is the baseline; control is an engineer's rite of passage.
Related
Related Content
Article
What Is Vibe Coding? Will It Change How Programmers Work?
Technical trends, engineering practices, and an indie developer perspective on Vibe Coding, AI, and independent development.
Article
From Copilot to Codex: AI Coding Tools Enter the Agent Era
Technical trends, engineering practices, and indie-developer perspectives around AI, AI Agents, and Vibe Coding.
Article
Complete Technology Stack Analysis for LLM Application Development
Covers tech trends, engineering practices, and indie developer perspectives around large models, RAG, and AI.
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.