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:
- Call stack — currently-executing functions.
- Task queue — pending tasks (timers, I/O callbacks).
- Microtask queue — Promise callbacks,
queueMicrotask. - Render — at certain points, the browser repaints.
Each tick:
- Run one task to completion (synchronous code in that task).
- Drain all microtasks.
- Possibly render.
- Next tick.
Implications:
- Sync code blocks —
while (true) {}freezes the page. - Microtasks run before the next task — Promise
thencallbacks beforesetTimeout. - 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.
awaitinside — 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 isolation — Cross-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
Cancellable search
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/awaitoverthenchains for substantial flow. - Use
Promise.allfor substantial parallel. - Use
Promise.allSettledwhen all results matter. - Use
AbortControllerfor substantial cancellation. - Use
AbortSignal.timeoutfor substantial timeouts. - Use
for await...offor substantial async iteration. - Avoid
forEachwithasync— usefor...oforPromise.all. - Always handle rejection — top-level
awaitpropagates; otherwise.catch. - Use Web Workers for substantial CPU-bound work.
- Use Service Workers for substantial offline.
- Reach for
queueMicrotaskoverPromise.resolve().thenfor substantial intent clarity. - Use
requestAnimationFramefor substantial animation. - Use
requestIdleCallbackfor 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.