Skip to content

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 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, 3

The setTimeout callback waits even with a 0 delay, because the currently running code must finish first. Nothing preempts a running function.

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 start
script end
promise 1
microtask
promise 2
timeout

Walk 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.

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);
});
});
});

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 fulfilled
Promise.reject(new Error('nope')); // already rejected

.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 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 1
await f(); // => 1

await works on any thenable, and on non-promises too (it wraps them, costing one microtask tick).

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();
}
}

This is the most common real performance bug in async code.

// Sequential — 3 round trips, ~900 ms
const a = await fetchThing('a'); // waits
const b = await fetchThing('b'); // then waits
const c = await fetchThing('c'); // then waits
// Parallel — all start immediately, ~300 ms
const [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 previous
for (const id of ids) {
results.push(await fetchThing(id));
}
// Parallel: start all, then wait once
const 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;
}

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 order
const [users, posts] = await Promise.all([fetchUsers(), fetchPosts()]);
// allSettled — you want every outcome, failures included
const 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 — timeouts
function 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 wins
const 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;
}
const id = setTimeout(fn, 1000); // run once after ~1000 ms
clearTimeout(id);
const iid = setInterval(fn, 1000); // run repeatedly
clearInterval(iid);
setTimeout((a, b) => console.log(a + b), 100, 1, 2); // extra args are passed through

The 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 ms

setInterval 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.

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(); // fixed
user.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;
}
}
  • One thread. The event loop runs one macrotask, then drains every microtask, then may render.
  • Promise reactions and post-await continuations are microtasks; timers, I/O, and DOM events are macrotasks. Promises always run before timers.
  • A promise settles once. .then returns a new promise, so chains stay flat — but you must return inside handlers.
  • async functions always return promises; await unwraps them and turns rejections into throwable exceptions.
  • await in a loop is sequential. Use Promise.all(items.map(fn)) for parallel, and never forEach with async.
  • all fails fast, allSettled never rejects, race takes the first settlement, any takes the first success. None of them cancel — use AbortController.
  • Timer delays are minimums; setInterval overlaps with slow async work, so self-schedule with setTimeout.
  • fetch only rejects on network-level failure; check res.ok, and read the body only once.