Polyglot
Languages Web (HTML / CSS / JS) concurrency
Web (HTML / CSS / JS) § concurrency

Async and promises

JavaScript is single-threaded — one line at a time on the main thread (the event loop). Concurrency comes from the asynchronous model: long-running operations (HTTP, timers, file reads) yield, allowing other work to run, and signal completion via callbacks, Promises, or async iterators. The principal mechanism: the Promise (then/catch/finally, with async/await sugar). For substantial parallelism, Web Workers run separate threads. The conventional contemporary patterns: async/await for substantial sequential async code, Promise.all for parallel, AbortController for cancellation. The combination — Promise as the principal abstraction, async/await over callback chains, the Promise.* helpers, the AbortController mechanism, the Worker for substantial background, the event loop with its microtask queue — is the substance of JavaScript concurrency.

The event loop

The principal mental model:

  1. Call stack — currently-executing functions.
  2. Task queue — pending tasks (timers, I/O callbacks).
  3. Microtask queue — Promise callbacks, queueMicrotask.
  4. Render — at certain points, the browser repaints.

Each tick:

  1. Run one task to completion (synchronous code in that task).
  2. Drain all microtasks.
  3. Possibly render.
  4. Next tick.

Implications:

  • Sync code blockswhile (true) {} freezes the page.
  • Microtasks run before the next task — Promise then callbacks before setTimeout.
  • No preemption — code runs to completion.
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");

// Output: 1, 4, 3, 2
// (sync, sync, microtask, task)

Promises

A Promise represents a future value:

const promise = new Promise((resolve, reject) => {
    setTimeout(() => resolve(42), 1000);
});

promise.then(value => console.log(value));         // 42 (after 1s)

Three states:

  • Pending — not yet settled.
  • Fulfilled — completed with a value.
  • Rejected — failed with a reason.

Once settled, the state is immutable.

then / catch / finally

fetch("/api/data")
    .then(r => r.json())                           // chain transforms
    .then(data => console.log(data))
    .catch(err => console.error(err))
    .finally(() => console.log("done"));

Each then returns a new Promise — admit substantial chaining.

async / await

The conventional contemporary syntax:

async function loadData() {
    try {
        const r = await fetch("/api/data");
        const data = await r.json();
        console.log(data);
    } catch (err) {
        console.error(err);
    } finally {
        console.log("done");
    }
}

The async admits:

  • Returns a Promise — even for sync values.
  • await inside — pauses until the awaited Promise settles.
  • try/catch — handles rejected awaits.
async function compute() {
    return 42;                                     // wraps in Promise
}

compute().then(v => console.log(v));               // 42

// At top level (ES modules):
const data = await loadData();

Promise.resolve / Promise.reject

const ok = Promise.resolve(42);
const fail = Promise.reject(new Error("oops"));

// Useful for converting sync to Promise:
async function getValue(key) {
    if (cache.has(key)) return Promise.resolve(cache.get(key));
    return fetchValue(key);
}

Promise combinators

Promise.all — all-or-nothing

const [users, posts, comments] = await Promise.all([
    fetch("/api/users").then(r => r.json()),
    fetch("/api/posts").then(r => r.json()),
    fetch("/api/comments").then(r => r.json())
]);

Rejects on the first rejection; the other Promises continue but their results are ignored.

Promise.allSettled — all results

const results = await Promise.allSettled([
    fetch("/api/a"),
    fetch("/api/b"),
    fetch("/api/c")
]);

for (const r of results) {
    if (r.status === "fulfilled") {
        console.log("ok", r.value);
    } else {
        console.log("err", r.reason);
    }
}

Settles when all settle; never rejects.

Promise.race — first to settle

const fastest = await Promise.race([
    fetch("/api/primary"),
    fetch("/api/backup")
]);

// Or with timeout:
const result = await Promise.race([
    fetch(url),
    new Promise((_, reject) =>
        setTimeout(() => reject(new Error("timeout")), 5000)
    )
]);

Settles with the first (success or failure).

Promise.any — first success

try {
    const result = await Promise.any([
        fetch("/api/primary"),
        fetch("/api/backup1"),
        fetch("/api/backup2")
    ]);
} catch (err) {
    // AggregateError if all rejected
    console.log(err.errors);
}

Settles with the first fulfilled; rejects only if all reject.

async patterns

Sequential

async function processSequential(items) {
    const results = [];
    for (const item of items) {
        const r = await processItem(item);
        results.push(r);
    }
    return results;
}

Parallel

async function processParallel(items) {
    return Promise.all(items.map(item => processItem(item)));
}

Bounded parallel

async function processBounded(items, concurrency = 3) {
    const results = [];
    const queue = [...items];
    const workers = Array(concurrency).fill().map(async () => {
        while (queue.length > 0) {
            const item = queue.shift();
            const r = await processItem(item);
            results.push(r);
        }
    });
    await Promise.all(workers);
    return results;
}

Sequential with error handling

async function processSeqAllSettled(items) {
    const results = [];
    for (const item of items) {
        try {
            results.push({ ok: true, value: await processItem(item) });
        } catch (err) {
            results.push({ ok: false, error: err });
        }
    }
    return results;
}

Reduce sequential

const final = await items.reduce(async (accP, item) => {
    const acc = await accP;
    const r = await processItem(item);
    return [...acc, r];
}, Promise.resolve([]));

Cancellation with AbortController

const controller = new AbortController();

async function fetchWithCancel() {
    try {
        const r = await fetch(url, { signal: controller.signal });
        return await r.json();
    } catch (err) {
        if (err.name === "AbortError") return null;
        throw err;
    }
}

// Cancel:
controller.abort();

The signal propagates to many APIs:

// Fetch:
fetch(url, { signal });

// Listening:
addEventListener("click", handler, { signal });

// Wait:
await new Promise((resolve, reject) => {
    setTimeout(resolve, 1000);
    signal.addEventListener("abort", () => reject(signal.reason), { once: true });
});

// Modern timeout:
await fetch(url, { signal: AbortSignal.timeout(5000) });

// Combined:
await fetch(url, { signal: AbortSignal.any([userSignal, timeoutSignal]) });

Timers

// One-shot:
const timer = setTimeout(() => {
    console.log("after 1s");
}, 1000);

clearTimeout(timer);

// Repeating:
const interval = setInterval(() => {
    console.log("every 1s");
}, 1000);

clearInterval(interval);

// Microtask:
queueMicrotask(() => {
    console.log("very soon");
});

// Animation frame:
const raf = requestAnimationFrame(() => {
    console.log("next paint");
});

cancelAnimationFrame(raf);

// Idle callback:
requestIdleCallback(deadline => {
    while (deadline.timeRemaining() > 0 && tasks.length > 0) {
        runTask(tasks.shift());
    }
});

Promise-based delay

function delay(ms, signal) {
    return new Promise((resolve, reject) => {
        const timer = setTimeout(resolve, ms);
        signal?.addEventListener("abort", () => {
            clearTimeout(timer);
            reject(signal.reason);
        }, { once: true });
    });
}

await delay(1000);
await delay(1000, controller.signal);

Async iteration

async function* readChunks(url) {
    const r = await fetch(url);
    const reader = r.body.getReader();
    while (true) {
        const { done, value } = await reader.read();
        if (done) return;
        yield value;
    }
}

for await (const chunk of readChunks("/big-file")) {
    process(chunk);
}

The for await...of admits substantial async iteration over async iterables.

async function* lines(stream) {
    const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
    let buffer = "";
    while (true) {
        const { done, value } = await reader.read();
        if (done) {
            if (buffer) yield buffer;
            return;
        }
        buffer += value;
        let idx;
        while ((idx = buffer.indexOf("\n")) >= 0) {
            yield buffer.slice(0, idx);
            buffer = buffer.slice(idx + 1);
        }
    }
}

for await (const line of lines(response.body)) {
    console.log(line);
}

Web Workers

For substantial CPU-bound work without blocking the main thread:

// main.js
const worker = new Worker("worker.js", { type: "module" });

worker.postMessage({ cmd: "compute", value: 42 });

worker.addEventListener("message", (e) => {
    console.log("result:", e.data);
});

worker.addEventListener("error", (err) => {
    console.error(err);
});

// Terminate:
worker.terminate();
// worker.js
self.addEventListener("message", (e) => {
    if (e.data.cmd === "compute") {
        const result = expensiveComputation(e.data.value);
        self.postMessage(result);
    }
});

function expensiveComputation(n) {
    // ...
}

Transferring data

For large data, transfer (not copy):

// Main:
const buf = new ArrayBuffer(1024 * 1024);
worker.postMessage({ buf }, [buf]);                // transfer ownership
console.log(buf.byteLength);                       // 0 — no longer accessible

// Worker:
self.addEventListener("message", (e) => {
    const { buf } = e.data;
    const view = new Uint8Array(buf);
    // process
    self.postMessage({ buf }, [buf]);              // transfer back
});

Promise wrapper

class WorkerPool {
    #workers = [];
    #queue = [];
    #pending = new Map();
    #nextId = 0;

    constructor(script, count = navigator.hardwareConcurrency) {
        for (let i = 0; i < count; i++) {
            const w = new Worker(script, { type: "module" });
            w.idle = true;
            w.addEventListener("message", (e) => {
                const { id, result, error } = e.data;
                const { resolve, reject } = this.#pending.get(id);
                this.#pending.delete(id);
                if (error) reject(error); else resolve(result);
                w.idle = true;
                this.#dequeue();
            });
            this.#workers.push(w);
        }
    }

    run(input) {
        return new Promise((resolve, reject) => {
            const id = this.#nextId++;
            this.#pending.set(id, { resolve, reject });
            this.#queue.push({ id, input });
            this.#dequeue();
        });
    }

    #dequeue() {
        const w = this.#workers.find(w => w.idle);
        if (w && this.#queue.length > 0) {
            const { id, input } = this.#queue.shift();
            w.idle = false;
            w.postMessage({ id, input });
        }
    }

    terminate() {
        this.#workers.forEach(w => w.terminate());
    }
}

const pool = new WorkerPool("compute-worker.js", 4);
const result = await pool.run({ value: 42 });

SharedArrayBuffer (with caveats)

const sab = new SharedArrayBuffer(1024);
const view = new Int32Array(sab);

// Main and workers both access the same memory:
worker.postMessage({ sab });

// In any thread:
Atomics.store(view, 0, 42);
Atomics.load(view, 0);
Atomics.add(view, 0, 1);
Atomics.wait(view, 0, 0);
Atomics.notify(view, 0);

Requires cross-origin isolationCross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers.

Service Workers

A separate worker that intercepts HTTP requests:

// main:
if ("serviceWorker" in navigator) {
    await navigator.serviceWorker.register("/sw.js");
}

// sw.js:
self.addEventListener("install", (e) => {
    e.waitUntil(caches.open("v1").then(c => c.addAll(["/", "/main.js"])));
});

self.addEventListener("fetch", (e) => {
    e.respondWith(
        caches.match(e.request).then(r => r ?? fetch(e.request))
    );
});

The mechanism admits substantial offline support and substantial caching.

Common patterns

let controller;

async function search(query) {
    controller?.abort();
    controller = new AbortController();
    const signal = controller.signal;

    try {
        const r = await fetch(`/search?q=${query}`, { signal });
        return await r.json();
    } catch (err) {
        if (err.name === "AbortError") return null;
        throw err;
    }
}

Retry with backoff

async function retry(fn, attempts = 3, baseMs = 100) {
    for (let i = 0; i <= attempts; i++) {
        try {
            return await fn();
        } catch (err) {
            if (i === attempts) throw err;
            await delay(baseMs * 2 ** i);
        }
    }
}

const data = await retry(() => fetch("/api/data").then(r => r.json()));

Debounce (Promise-aware)

function asyncDebounce(fn, ms) {
    let timer;
    let pending;
    return (...args) => {
        clearTimeout(timer);
        if (!pending) {
            pending = new Promise((resolve, reject) => {
                timer = setTimeout(async () => {
                    try {
                        const r = await fn(...args);
                        resolve(r);
                    } catch (e) {
                        reject(e);
                    } finally {
                        pending = null;
                    }
                }, ms);
            });
        }
        return pending;
    };
}

Mutex

class Mutex {
    #queue = Promise.resolve();

    run(fn) {
        const result = this.#queue.then(fn);
        this.#queue = result.catch(() => {});      // ignore errors for queue
        return result;
    }
}

const mutex = new Mutex();
await mutex.run(async () => {
    // exclusive section
});

Semaphore

class Semaphore {
    #count;
    #waiters = [];

    constructor(max) { this.#count = max; }

    async acquire() {
        if (this.#count > 0) {
            this.#count--;
        } else {
            await new Promise(resolve => this.#waiters.push(resolve));
        }
    }

    release() {
        if (this.#waiters.length > 0) {
            const resolve = this.#waiters.shift();
            resolve();
        } else {
            this.#count++;
        }
    }
}

const sem = new Semaphore(3);
async function task() {
    await sem.acquire();
    try { /* ... */ } finally { sem.release(); }
}

Promise from event

function once(target, type, signal) {
    return new Promise((resolve, reject) => {
        target.addEventListener(type, resolve, { once: true, signal });
        signal?.addEventListener("abort", () => reject(signal.reason), { once: true });
    });
}

await once(button, "click");

Promise queue

class PromiseQueue {
    #queue = Promise.resolve();

    add(fn) {
        const result = this.#queue.then(fn);
        this.#queue = result.catch(() => {});
        return result;
    }
}

const queue = new PromiseQueue();
queue.add(() => task1());
queue.add(() => task2());
queue.add(() => task3());

Defer

function defer() {
    let resolve, reject;
    const promise = new Promise((res, rej) => {
        resolve = res;
        reject = rej;
    });
    return { promise, resolve, reject };
}

// Or modern:
const { promise, resolve, reject } = Promise.withResolvers();

Lazy promise

function lazy(fn) {
    let promise;
    return () => promise ??= fn();
}

const getConfig = lazy(() => fetch("/config").then(r => r.json()));
const c1 = await getConfig();                      // fetches
const c2 = await getConfig();                      // same Promise

Fan-out then aggregate

async function getUserDetails(userIds) {
    const users = await Promise.all(userIds.map(id =>
        fetch(`/api/users/${id}`).then(r => r.json())
    ));

    const allPosts = await Promise.all(users.map(u =>
        fetch(`/api/users/${u.id}/posts`).then(r => r.json())
    ));

    return users.map((u, i) => ({ ...u, posts: allPosts[i] }));
}

Race with timeout

async function withTimeout(promise, ms) {
    let timer;
    const timeout = new Promise((_, reject) => {
        timer = setTimeout(() => reject(new Error("timeout")), ms);
    });

    try {
        return await Promise.race([promise, timeout]);
    } finally {
        clearTimeout(timer);
    }
}

Auto-retry network failures

async function fetchWithRetry(url, options = {}, retries = 3) {
    for (let i = 0; i <= retries; i++) {
        try {
            const r = await fetch(url, options);
            if (r.ok || r.status < 500) return r;
        } catch (err) {
            if (i === retries) throw err;
        }
        await delay(2 ** i * 100);
    }
}

Offload heavy work

const worker = new Worker("/work.js", { type: "module" });

function offload(input) {
    return new Promise((resolve, reject) => {
        worker.addEventListener("message", function fn(e) {
            worker.removeEventListener("message", fn);
            if (e.data.error) reject(e.data.error);
            else resolve(e.data.result);
        });
        worker.postMessage(input);
    });
}

const result = await offload({ task: "encode", data: bigBuffer });

A note on common pitfalls

// Forgetting await:
async function load() {
    const data = fetch(url);                       // missing await
    return data;                                   // returns Promise<Response>, not value
}

// Mixing then with await:
const r = await fetch(url).then(r => r.json());    // fine but not idiomatic
const r = await (await fetch(url)).json();         // explicit but ugly
const r = await fetch(url);
const data = await r.json();                       // canonical

// Forgetting to handle rejection:
asyncWork();                                       // unhandled rejection if it fails
asyncWork().catch(err => console.error(err));      // safe

// Misusing forEach:
items.forEach(async item => {
    await process(item);                           // forEach ignores return
});
// Use for...of or Promise.all instead.

// Sequential when parallel intended:
const a = await fetch("/a");
const b = await fetch("/b");                       // waits for a unnecessarily

// Better:
const [a, b] = await Promise.all([fetch("/a"), fetch("/b")]);

A note on the conventional discipline

The contemporary concurrency advice:

  • Use async/await over then chains for substantial flow.
  • Use Promise.all for substantial parallel.
  • Use Promise.allSettled when all results matter.
  • Use AbortController for substantial cancellation.
  • Use AbortSignal.timeout for substantial timeouts.
  • Use for await...of for substantial async iteration.
  • Avoid forEach with async — use for...of or Promise.all.
  • Always handle rejection — top-level await propagates; otherwise .catch.
  • Use Web Workers for substantial CPU-bound work.
  • Use Service Workers for substantial offline.
  • Reach for queueMicrotask over Promise.resolve().then for substantial intent clarity.
  • Use requestAnimationFrame for substantial animation.
  • Use requestIdleCallback for substantial low-priority work.

The combination — single-threaded event loop, Promise as the principal abstraction, async/await over callbacks, the substantial Promise combinators, the AbortController for substantial cancellation, the Worker for substantial parallelism — is the substance of JavaScript concurrency. The discipline produces substantial responsive, cancellable, well-orchestrated async code.