Async and concurrency
JavaScript (and therefore TypeScript) is single-threaded in the conventional execution model: a single event loop processes events sequentially. Asynchrony is achieved through Promises and the async/await syntax — admitting non-blocking I/O without parallel execution. For genuine parallelism, Web Workers (browser) and Worker Threads (Node.js) admit separate execution contexts with message passing. The combination — Promises for async composition, async/await for concise non-blocking code, the event loop for cooperative scheduling, workers for parallel computation — is the substance of TypeScript’s concurrency story.
Promises
A Promise represents a value that may be available now, in the future, or never:
const p: Promise<number> = new Promise((resolve, reject) => {
setTimeout(() => resolve(42), 1000);
});
p.then(n => console.log(n)); // 42 (after 1 second)
A Promise has three states: pending, fulfilled (with a value), or rejected (with an error).
The principal methods:
p.then(onFulfilled); // chain on success
p.then(onFulfilled, onRejected); // chain on success or failure
p.catch(onRejected); // chain on failure
p.finally(onSettled); // chain on either
const chain = p
.then(n => n * 2)
.then(n => `value: ${n}`)
.catch(e => `error: ${e}`)
.finally(() => console.log("done"));
Each .then returns a new Promise; the chain admits substantial composition. The conventional contemporary form prefers async/await over explicit .then chains.
async/await
The async keyword marks an async function — returning a Promise:
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
const user = await fetchUser("123");
console.log(user.name);
The await suspends the async function until the awaited Promise resolves; the syntax admits substantial conciseness compared with .then chains:
// Promise chain:
function fetchUser(id: string): Promise<User> {
return fetch(`/api/users/${id}`)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
});
}
// async/await (equivalent, conventional):
async function fetchUser(id: string): Promise<User> {
const r = await fetch(`/api/users/${id}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
The async function always returns a Promise; the return value resolves the Promise with value; throw error rejects it.
Top-level await
In ES modules (since ES2022), await may be used at the top level:
// In a module:
const data = await fetch("/api/config").then(r => r.json());
console.log(data);
The form admits substantial simplification of module initialisation; the importing modules wait for the export before continuing.
Combining promises
Promise.all
const [user, posts, comments] = await Promise.all([
fetchUser(id),
fetchPosts(id),
fetchComments(id),
]);
The Promise.all resolves with an array of values when all input Promises fulfil; rejects with the first error if any rejects.
Promise.allSettled
const results = await Promise.allSettled([
fetchA(),
fetchB(),
fetchC(),
]);
for (const r of results) {
if (r.status === "fulfilled") {
console.log("success:", r.value);
} else {
console.log("error:", r.reason);
}
}
The Promise.allSettled waits for all inputs; never rejects. Conventional for “do all of these; report what succeeded and what failed”.
Promise.race
const winner = await Promise.race([
operation(),
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000)),
]);
The Promise.race resolves or rejects with the first input that settles. Conventional for timeouts and “first available” patterns.
Promise.any
const first = await Promise.any([
fetchFromA(),
fetchFromB(),
fetchFromC(),
]);
The Promise.any resolves with the first fulfilled Promise; rejects only if all inputs reject. Conventional for “first success” patterns.
The event loop
JavaScript’s runtime is single-threaded; the event loop processes:
- The call stack — synchronous code.
- Microtasks — Promise continuations (
.then,await). - Macrotasks —
setTimeout,setInterval, I/O callbacks.
The principal sources of async work:
- I/O — file system, network, etc.
- Timers —
setTimeout,setInterval. - User events — clicks, keypresses (browser).
- Promise resolution —
Promise.resolve().then(...).
A common pitfall: substantial synchronous work blocks the event loop, freezing the UI or preventing other async work. The conventional defences:
setTimeoutfor chunking — split work into smaller pieces.- Web Workers / Worker Threads — offload CPU-bound work.
- Async iterables — process streams piece by piece.
Async iterators
The for await...of loop iterates over async iterables:
async function* range(start: number, end: number): AsyncGenerator<number> {
for (let i = start; i < end; i++) {
await sleep(100);
yield i;
}
}
for await (const n of range(0, 5)) {
console.log(n);
}
The async function* syntax produces an async generator. Conventional uses are streaming I/O, paginated APIs, and event streams.
Web Workers (browser)
For genuine parallelism in the browser:
// worker.ts:
self.onmessage = (e: MessageEvent<number>) => {
const result = expensiveComputation(e.data);
self.postMessage(result);
};
// main.ts:
const worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" });
worker.onmessage = (e: MessageEvent<number>) => {
console.log("result:", e.data);
};
worker.postMessage(42);
Workers run in a separate thread; they communicate via message passing. The conventional uses are CPU-intensive work that would otherwise block the UI.
For substantial worker abstractions, libraries like comlink admit RPC-style communication.
Worker Threads (Node.js)
// worker.ts:
import { parentPort } from "node:worker_threads";
parentPort?.on("message", (data: unknown) => {
const result = process(data);
parentPort?.postMessage(result);
});
// main.ts:
import { Worker } from "node:worker_threads";
const worker = new Worker(new URL("./worker.ts", import.meta.url));
worker.on("message", result => console.log(result));
worker.postMessage(input);
Worker Threads admit CPU-bound parallel work in Node.js. The conventional alternatives are cluster mode (multiple processes) and child_process (spawning subprocesses).
AbortController
The AbortController admits cancelling async operations:
const controller = new AbortController();
const promise = fetch("/api", { signal: controller.signal });
// Later, cancel:
controller.abort();
try {
const response = await promise;
} catch (e) {
if (e instanceof Error && e.name === "AbortError") {
console.log("Cancelled");
}
}
The signal propagates to APIs that support it; the conventional contemporary discipline supports AbortSignal in async APIs:
async function operation(signal?: AbortSignal): Promise<string> {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
// ... do work, checking signal.aborted periodically ...
}
Common patterns
Sequential async
async function process(items: Item[]): Promise<Result[]> {
const results: Result[] = [];
for (const item of items) {
results.push(await processOne(item)); // sequential
}
return results;
}
Parallel async
async function process(items: Item[]): Promise<Result[]> {
return Promise.all(items.map(processOne)); // parallel
}
Bounded parallelism
async function processConcurrent<T, R>(
items: T[],
fn: (item: T) => Promise<R>,
concurrency: number,
): Promise<R[]> {
const results: R[] = [];
const queue = [...items];
async function worker(): Promise<void> {
while (queue.length > 0) {
const item = queue.shift()!;
const result = await fn(item);
results.push(result);
}
}
await Promise.all(Array.from({ length: concurrency }, worker));
return results;
}
The pattern admits processing N items at a time; conventional in throttled API consumers.
Timeout wrapper
async function withTimeout<T>(
promise: Promise<T>,
ms: number,
): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms);
}),
]);
}
const result = await withTimeout(fetchData(), 5000);
Retry with exponential backoff
async function retry<T>(
fn: () => Promise<T>,
attempts = 3,
baseDelay = 100,
): Promise<T> {
let lastError: unknown;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (e) {
lastError = e;
if (i < attempts - 1) {
await sleep(baseDelay * 2 ** i);
}
}
}
throw lastError;
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
Debounce
function debounce<A extends unknown[]>(
fn: (...args: A) => void,
delay: number,
): (...args: A) => void {
let timeout: ReturnType<typeof setTimeout> | null = null;
return (...args: A) => {
if (timeout !== null) clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), delay);
};
}
const handleSearch = debounce((query: string) => {
fetchSearch(query);
}, 300);
The pattern delays execution until input stops; conventional for search-as-you-type.
Throttle
function throttle<A extends unknown[]>(
fn: (...args: A) => void,
interval: number,
): (...args: A) => void {
let lastCall = 0;
return (...args: A) => {
const now = Date.now();
if (now - lastCall >= interval) {
lastCall = now;
fn(...args);
}
};
}
The pattern admits at most one call per interval; conventional for scroll handlers and resize.
Cancellation with AbortController
async function searchUsers(query: string, signal?: AbortSignal): Promise<User[]> {
const response = await fetch(`/api/users?q=${query}`, { signal });
return response.json();
}
let controller: AbortController | null = null;
async function search(query: string): Promise<User[]> {
controller?.abort(); // cancel any in-flight
controller = new AbortController();
return searchUsers(query, controller.signal);
}
Async generator for streaming
async function* fetchPaged<T>(url: string): AsyncGenerator<T> {
let next: string | null = url;
while (next !== null) {
const r = await fetch(next);
const data: { items: T[]; next?: string } = await r.json();
for (const item of data.items) yield item;
next = data.next ?? null;
}
}
for await (const item of fetchPaged<User>("/api/users")) {
process(item);
}
Promise queue
class PromiseQueue {
private queue: Promise<unknown> = Promise.resolve();
enqueue<T>(fn: () => Promise<T>): Promise<T> {
const result = this.queue.then(() => fn());
this.queue = result.catch(() => {}); // ignore rejections in queue chain
return result;
}
}
const queue = new PromiseQueue();
queue.enqueue(() => task1());
queue.enqueue(() => task2()); // runs after task1
queue.enqueue(() => task3()); // runs after task2
The pattern admits serialised execution of async tasks.
Channels (loose ports)
For substantial communication patterns, libraries provide channel/queue abstractions. The standard library does not include channels.
Generator-based scheduling
async function* tasks() {
yield await fetchA();
yield await fetchB();
yield await fetchC();
}
for await (const result of tasks()) {
console.log(result);
}
Error handling in concurrent operations
const results = await Promise.allSettled([
a(),
b(),
c(),
]);
const errors = results
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
.map(r => r.reason);
if (errors.length > 0) {
throw new AggregateError(errors, "Some operations failed");
}
Web Worker pool
class WorkerPool<I, O> {
private workers: Worker[];
private queue: Array<{ input: I; resolve: (output: O) => void }> = [];
private idle: Worker[];
constructor(url: URL, count: number) {
this.workers = Array.from({ length: count }, () =>
new Worker(url, { type: "module" }));
this.idle = [...this.workers];
for (const w of this.workers) {
w.addEventListener("message", () => { /* dispatch from queue */ });
}
}
submit(input: I): Promise<O> {
return new Promise(resolve => {
this.queue.push({ input, resolve });
this.dispatch();
});
}
private dispatch(): void {
while (this.queue.length > 0 && this.idle.length > 0) {
const worker = this.idle.shift()!;
const task = this.queue.shift()!;
// ... post task to worker; on response, return worker to idle, resolve task
}
}
}
A note on the conventional discipline
The contemporary TypeScript concurrency advice:
- Use
async/awaitover explicit.thenchains. - Use
Promise.allfor concurrent independent operations. - Use
Promise.allSettledwhen partial failure is acceptable. - Use
Promise.racefor timeouts and “first available”. - Use
AbortControllerfor cancellation. - Use top-level
awaitin modules where appropriate. - Use
for await...offor async iteration. - Use Web Workers / Worker Threads for CPU-bound parallel work.
- Use bounded parallelism for substantial concurrent operations.
- Don’t block the event loop — chunk substantial work or offload to workers.
- Handle rejections — unhandled rejections crash modern Node.js.
The combination — Promises for async composition, async/await for concise code, the event loop for cooperative scheduling, async iterators for streaming, AbortController for cancellation, workers for parallelism — is the substance of TypeScript’s concurrency story. The discipline trades parallelism on the main thread for substantial simplicity; the genuine parallelism is reserved for explicit worker contexts.