Async JavaScript
JavaScript runs your code on a single thread. Anything slow — a network request, a file read, a timer — is handed to the host environment, which notifies you later. Understanding the queueing model behind that notification is what makes async code predictable instead of mysterious.
Synchronous vs asynchronous
Section titled “Synchronous vs asynchronous”Synchronous code runs to completion before anything else can run. Asynchronous code starts an operation and returns immediately; the result arrives later.
console.log('1');setTimeout(() => console.log('3'), 0);console.log('2');// => 1, 2, 3The setTimeout callback waits even with a 0 delay, because the currently running code must finish first. Nothing preempts a running function.
The runtime model
Section titled “The runtime model”Four pieces work together.
Call stack. Frames for the functions currently executing. Calling pushes, returning pops. When the stack is empty, the current task is done.
Host APIs. setTimeout, fetch, file I/O, DOM events. These are provided by the browser or Node, not by the language, and they do their work outside your thread.
Task queue (macrotasks). Callbacks waiting for their turn: timer callbacks, I/O completions, DOM events.
Microtask queue. A higher-priority queue: promise reactions (.then/.catch/.finally and everything after an await), plus queueMicrotask and MutationObserver callbacks.
The event loop is the rule that ties them together:
repeat forever: 1. Run one macrotask to completion (the whole synchronous call stack). 2. Drain the ENTIRE microtask queue — including microtasks added while draining. 3. (In a browser) render if it is time to paint.The critical part is step 2: all microtasks run before the next macrotask. Promises always beat timers.
console.log('script start');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve() .then(() => console.log('promise 1')) .then(() => console.log('promise 2'));
queueMicrotask(() => console.log('microtask'));
console.log('script end');script startscript endpromise 1microtaskpromise 2timeoutWalk through it: both console.logs run synchronously; the timer callback goes to the macrotask queue; promise 1 and microtask are already queued as microtasks. The stack empties, the microtask queue drains — and promise 2, queued during the drain, still runs before the timer.
Callbacks
Section titled “Callbacks”The original async pattern: pass a function to be called on completion. Node’s convention is “error-first” — the callback receives (err, result).
import { readFile } from 'node:fs';
readFile('data.txt', 'utf8', (err, data) => { if (err) { console.error('failed:', err.message); return; } console.log(data);});Callbacks work, but nesting dependent operations produces “callback hell”: deep indentation, error handling repeated at every level, and no way to use try/catch or return.
getUser(id, (err, user) => { if (err) return handle(err); getOrders(user.id, (err, orders) => { if (err) return handle(err); getItems(orders[0].id, (err, items) => { if (err) return handle(err); console.log(items); }); });});Promises
Section titled “Promises”A promise is an object representing a value that is not ready yet. It has three states:
- pending — not settled
- fulfilled — settled with a value
- rejected — settled with a reason (should be an
Error)
A promise settles once; after that its state and value never change.
const promise = new Promise((resolve, reject) => { setTimeout(() => { const ok = Math.random() > 0.5; ok ? resolve('worked') : reject(new Error('failed')); }, 100);});
promise .then((value) => console.log('fulfilled:', value)) .catch((err) => console.error('rejected:', err.message)) .finally(() => console.log('done either way'));Ready-made promises:
Promise.resolve(42); // already fulfilledPromise.reject(new Error('nope')); // already rejectedChaining
Section titled “Chaining”.then always returns a new promise, which is what makes chaining flat instead of nested.
- Returning a plain value from a handler fulfills the next promise with it.
- Returning a promise makes the next link wait for it.
- Throwing rejects the next promise.
fetch('/api/user') .then((res) => res.json()) // returns a promise → chain waits .then((user) => user.name.toUpperCase()) // plain value .then((name) => console.log(name)) .catch((err) => console.error(err)); // catches any rejection above.catch(fn) is shorthand for .then(undefined, fn). It handles rejections from every earlier link, which is why one catch at the end of a chain is enough.
A handler after a .catch still runs, because catch returns a fulfilled promise — that is how you recover:
fetchUser() .catch(() => ({ name: 'anonymous' })) // fallback value .then((user) => render(user)); // always runs.finally(fn) runs on either outcome, receives no argument, and passes the original value or rejection through untouched. Use it for cleanup (hide a spinner, close a handle).
async / await
Section titled “async / await”async/await is syntax over promises. An async function always returns a promise; await pauses that function until a promise settles, then evaluates to its value (or throws its rejection).
async function getUserName(id) { const res = await fetch(`/api/users/${id}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const user = await res.json(); return user.name; // fulfils the returned promise with this}
getUserName(1).then(console.log);The equivalent chained version is the same thing, just harder to read once branching is involved.
async function f() { return 1; }f(); // => Promise { 1 } — not 1await f(); // => 1await works on any thenable, and on non-promises too (it wraps them, costing one microtask tick).
Error handling with try/catch
Section titled “Error handling with try/catch”This is the main ergonomic win: async errors become ordinary exceptions.
async function load(id) { try { const res = await fetch(`/api/items/${id}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } catch (err) { console.error('load failed:', err.message); return null; // or rethrow } finally { hideSpinner(); }}Sequential vs parallel
Section titled “Sequential vs parallel”This is the most common real performance bug in async code.
// Sequential — 3 round trips, ~900 msconst a = await fetchThing('a'); // waitsconst b = await fetchThing('b'); // then waitsconst c = await fetchThing('c'); // then waits// Parallel — all start immediately, ~300 msconst [a, b, c] = await Promise.all([ fetchThing('a'), fetchThing('b'), fetchThing('c'),]);Sequential is correct only when a later call needs an earlier result. Otherwise start them together.
The same bug hides inside loops:
// Sequential: each iteration waits for the previousfor (const id of ids) { results.push(await fetchThing(id));}
// Parallel: start all, then wait onceconst results = await Promise.all(ids.map((id) => fetchThing(id)));Parallel is not always right either: firing 10,000 requests at once will hit rate limits or exhaust sockets. Batch them:
async function inBatches(items, size, fn) { const out = []; for (let i = 0; i < items.length; i += size) { const batch = items.slice(i, i + size); out.push(...await Promise.all(batch.map(fn))); } return out;}Promise combinators
Section titled “Promise combinators”Four static methods for coordinating multiple promises.
| Method | Fulfils when | Rejects when | Result |
|---|---|---|---|
Promise.all |
all fulfil | any rejects (immediately) | array of values |
Promise.allSettled |
all settle | never | array of {status, value/reason} |
Promise.race |
first settles | first settles as a rejection | that one value/reason |
Promise.any |
first fulfils | all reject | first value / AggregateError |
// all — fail fast, order preserved regardless of completion orderconst [users, posts] = await Promise.all([fetchUsers(), fetchPosts()]);// allSettled — you want every outcome, failures includedconst results = await Promise.allSettled([fetchA(), fetchB()]);for (const r of results) { if (r.status === 'fulfilled') console.log('ok:', r.value); else console.warn('failed:', r.reason.message);}// race — timeoutsfunction withTimeout(promise, ms) { const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms)); return Promise.race([promise, timeout]);}await withTimeout(fetch('/slow'), 5000);// any — first success winsconst fastest = await Promise.any([fetch(mirror1), fetch(mirror2)]);To actually cancel work, use AbortController:
const controller = new AbortController();setTimeout(() => controller.abort(), 5000);
try { const res = await fetch('/slow', { signal: controller.signal }); const data = await res.json();} catch (err) { if (err.name === 'AbortError') console.log('cancelled'); else throw err;}Timers
Section titled “Timers”const id = setTimeout(fn, 1000); // run once after ~1000 msclearTimeout(id);
const iid = setInterval(fn, 1000); // run repeatedlyclearInterval(iid);
setTimeout((a, b) => console.log(a + b), 100, 1, 2); // extra args are passed throughThe delay is a minimum, not a guarantee. The callback runs only when the stack is empty and its turn comes.
setTimeout(() => console.log('after'), 10);const end = Date.now() + 500;while (Date.now() < end) {} // blocks// 'after' fires around 500 ms, not 10 mssetInterval does not wait for async work to finish, so slow callbacks overlap. A self-scheduling setTimeout is safer:
async function poll() { try { await checkForUpdates(); } finally { setTimeout(poll, 5000); // next run scheduled only after this one finishes }}poll();A promisified sleep, useful for retries and backoff:
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function retry(fn, attempts = 3) { for (let i = 0; i < attempts; i++) { try { return await fn(); } catch (err) { if (i === attempts - 1) throw err; await sleep(2 ** i * 100); // 100 ms, 200 ms, 400 ms } }}In Node, queueMicrotask and process.nextTick both jump the timer queue; setImmediate runs after I/O callbacks in the same loop iteration.
fetch returns a promise for a Response. Reading the body is a second promise.
async function getJSON(url) { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`); return res.json();}
const data = await getJSON('https://api.github.com/repos/nodejs/node');console.log(data.full_name); // => 'nodejs/node'A POST with a JSON body:
const res = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Ada' }),});Response bodies are streams and can only be read once. Pick one of res.json(), res.text(), res.blob(), res.arrayBuffer(), or res.formData(). Call res.clone() first if you truly need two reads.
fetch is available in browsers and in Node 18+ without any import.
Common pitfalls, collected
Section titled “Common pitfalls, collected”Missing await. The most frequent async bug. You get a Promise object where you expected a value, and errors become unhandled rejections.
const broken = getUser(); // => Promise { <pending> }broken.name; // => undefined
const user = await getUser(); // fixeduser.name; // => 'Ada'Mixing await and .then. Pick one style per function; mixing hides control flow.
Unhandled rejections. Every promise chain needs a terminal .catch, or its await needs a try/catch. In Node, an unhandled rejection terminates the process by default.
process.on('unhandledRejection', (reason) => { console.error('unhandled:', reason); process.exit(1);});Creating promises you never await. Kicking off work and dropping the handle means errors vanish. If it is deliberate (“fire and forget”), attach .catch(console.error) explicitly.
Assuming await yields to the browser. Awaiting an already-resolved promise costs one microtask tick — it does not let the page render. To yield for a paint, await new Promise(r => setTimeout(r, 0)).
Async constructors. A constructor cannot be async. Use a static factory:
class Db { static async connect(url) { const db = new Db(); db.conn = await open(url); return db; }}Key points
Section titled “Key points”- One thread. The event loop runs one macrotask, then drains every microtask, then may render.
- Promise reactions and post-
awaitcontinuations are microtasks; timers, I/O, and DOM events are macrotasks. Promises always run before timers. - A promise settles once.
.thenreturns a new promise, so chains stay flat — but you mustreturninside handlers. asyncfunctions always return promises;awaitunwraps them and turns rejections into throwable exceptions.awaitin a loop is sequential. UsePromise.all(items.map(fn))for parallel, and neverforEachwithasync.allfails fast,allSettlednever rejects,racetakes the first settlement,anytakes the first success. None of them cancel — useAbortController.- Timer delays are minimums;
setIntervaloverlaps with slow async work, so self-schedule withsetTimeout. fetchonly rejects on network-level failure; checkres.ok, and read the body only once.