Polyglot
Languages TypeScript concurrency
TypeScript § concurrency

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:

  1. The call stack — synchronous code.
  2. Microtasks — Promise continuations (.then, await).
  3. MacrotaskssetTimeout, setInterval, I/O callbacks.

The principal sources of async work:

  • I/O — file system, network, etc.
  • TimerssetTimeout, setInterval.
  • User events — clicks, keypresses (browser).
  • Promise resolutionPromise.resolve().then(...).

A common pitfall: substantial synchronous work blocks the event loop, freezing the UI or preventing other async work. The conventional defences:

  • setTimeout for 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/await over explicit .then chains.
  • Use Promise.all for concurrent independent operations.
  • Use Promise.allSettled when partial failure is acceptable.
  • Use Promise.race for timeouts and “first available”.
  • Use AbortController for cancellation.
  • Use top-level await in modules where appropriate.
  • Use for await...of for 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.