JavaScript Async and the Event Loop
- Description: The event loop, callbacks, Promises, async/await, error handling across async boundaries, microtasks vs macrotasks, and concurrency patterns (
Promise.all,Promise.race, …). - My Notion Note ID: K2A-F4-6
- Created: 2018-03-23
- Updated: 2026-05-17
- License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io
Table of Contents
- 1. The Single-Threaded Model
- 2. The Event Loop
- 3. Timers and Scheduling
- 4. Callbacks — the Original Async Pattern
- 5. Promises
- 6. async / await
- 7. Error Handling
- 8. Concurrency Patterns
- 9. Streams and Async Iteration
- 10. References
1. The Single-Threaded Model
JavaScript runs on a single thread. There is one call stack, one heap, one task queue. Async APIs don't introduce parallelism — they let the runtime do work elsewhere (network, timer, disk) while JS waits, then queue a callback for the engine to pick up later.
Two consequences:
- No race conditions on shared variables. A function runs to completion before any other code can mutate the same state.
- A blocking computation freezes the page — UI thread is the same thread. Heavy work belongs on a
WorkerorWebAssemblythread.
Web Workers / Service Workers / Worklets run on separate threads, communicating via message passing. They have separate event loops.
2. The Event Loop
┌──────────────────┐
│ Call Stack │ ← currently executing
└────────┬─────────┘
│ (pop when empty)
┌────────▼─────────┐
│ Microtask queue │ ← promise callbacks, queueMicrotask
└────────┬─────────┘
│ (drain completely between macrotasks)
┌────────▼─────────┐
│ Macrotask queue │ ← setTimeout, setInterval, I/O, UI events
└──────────────────┘
One iteration ("tick") of the loop:
- Take one macrotask, run it to completion (synchronous code + scheduled microtasks).
- Drain the microtask queue completely before the next tick.
- (Browser only) Render if needed.
- Repeat.
Microtasks scheduled inside microtasks keep extending the current drain — a poorly written promise-recursion can starve rendering.
console.log('1');
setTimeout(() => console.log('2'), 0); // macrotask
Promise.resolve().then(() => console.log('3')); // microtask
queueMicrotask(() => console.log('4')); // microtask
console.log('5');
// Output: 1, 5, 3, 4, 2
Sync code runs first (1, 5), then microtasks (3, 4), then the next macrotask (2).
3. Timers and Scheduling
setTimeout(fn, ms); // macrotask after delay
setInterval(fn, ms); // recurring macrotask
clearTimeout(id);
clearInterval(id);
queueMicrotask(fn); // before next macrotask
requestAnimationFrame(fn); // before next paint (~60Hz)
requestIdleCallback(fn); // when main thread is idle
setTimeout(fn, 0) is not immediate — it queues a macrotask. Minimum delay is clamped (4ms after 5 nested timeouts in browsers).
setInterval drifts if a callback runs longer than the interval. For precise pacing, use chained setTimeout or requestAnimationFrame.
In Node-only environments: process.nextTick(fn) runs before microtasks; rarely needed.
4. Callbacks — the Original Async Pattern
fs.readFile('a.txt', (err, data) => {
if (err) return handle(err);
// ...
});
Node-style: err-first callback. Errors as first arg, data as second.
The pain:
- Deeply nested code ("callback hell").
- Inconsistent error handling — easy to forget to check
err. - Hard to compose (parallel, race, retry).
Promises were designed to fix these.
5. Promises
A Promise is an object representing the eventual completion (or failure) of an async operation. Three states:
- pending — not settled.
- fulfilled — settled with a value.
- rejected — settled with a reason.
Once settled, it's immutable.
const p = new Promise((resolve, reject) => {
doAsync((err, data) => {
if (err) reject(err);
else resolve(data);
});
});
p
.then(value => /* fulfilled */)
.catch(err => /* rejected */)
.finally(() => /* settled either way */);
5.1 Chaining
Each then / catch returns a new promise:
fetch(url)
.then(resp => resp.json())
.then(data => transform(data))
.then(result => render(result))
.catch(err => showError(err));
Throwing inside a then becomes a rejection. Returning a promise from then causes the chain to wait on it. Returning a value forwards it as the next then's resolution.
5.2 Static Combinators
| Method | Settles when |
|---|---|
Promise.all([p1, p2, …]) |
All fulfilled (→ array of values) or any rejected (→ that reason). |
Promise.allSettled([…]) |
All settled. Returns { status, value? , reason? }[]. Never rejects. |
Promise.race([…]) |
First to settle, either way. |
Promise.any([…]) |
First to fulfil. Rejects with AggregateError if all reject. |
const [user, posts] = await Promise.all([fetchUser(), fetchPosts()]);
const results = await Promise.allSettled(urls.map(fetch));
const successes = results.filter(r => r.status === 'fulfilled').map(r => r.value);
Promise.all short-circuits on first rejection — other operations keep running, but you stop waiting for them. Use allSettled if you want every result, success or failure.
5.3 Promise.resolve / reject / try
Promise.resolve(42); // pre-resolved
Promise.reject(new Error('nope')); // pre-rejected
Promise.try(() => maybeAsync()); // ES2025 — wraps sync throws as rejections
Promise.try simplifies "this function might be sync or async, errors might be thrown or returned":
// Without
const p = new Promise((res) => res()).then(() => maybeAsync());
// With
const p = Promise.try(() => maybeAsync());
5.4 Promise.withResolvers (ES2024)
const { promise, resolve, reject } = Promise.withResolvers();
// pass `resolve` and `reject` elsewhere, return `promise` now
Cleaner than the let res; const p = new Promise(r => res = r); dance.
6. async / await
async functions return a Promise. await pauses execution until the awaited value settles.
async function load(url) {
const resp = await fetch(url);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
return data; // becomes the fulfilled value
}
const data = await load('/api/users'); // top-level await in modules
Equivalent without async/await:
function load(url) {
return fetch(url)
.then(resp => {
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.json();
});
}
async/await is sugar over Promises. Same runtime semantics, much easier to read.
6.1 Sequential vs Parallel
// Sequential — second request waits for first
const a = await fetch('/a');
const b = await fetch('/b');
// Parallel — both kicked off, then awaited
const [a, b] = await Promise.all([fetch('/a'), fetch('/b')]);
The mistake is easy to miss. If await'ed values are independent, kick them off in parallel.
6.2 Top-Level await
Available in ES modules:
// module.mjs
const config = await loadConfig();
export { config };
Top-level await delays module evaluation. Consumers transitively wait.
7. Error Handling
async function safe() {
try {
return await load(url);
} catch (err) {
if (err instanceof NetworkError) return null;
throw err; // re-throw what you don't handle
} finally {
cleanup();
}
}
Promise chain equivalent:
load(url)
.then(...)
.catch(err => {
if (err instanceof NetworkError) return null;
throw err;
})
.finally(cleanup);
7.1 Unhandled Rejections
A promise that rejects with no .catch (and never gets one) triggers unhandledrejection in the browser, or process.unhandledRejection in Node. Always:
- Add
.catchto the end of a promise chain you start. awaitinside atry/catch, or attach.catchto the promise you store.
Easy footgun:
async function go() { throw new Error('x'); }
go(); // unhandled rejection — no await, no .catch
ESLint's no-floating-promises (TS) and no-misused-promises catch most of these.
7.2 AbortController
Cancel an in-flight request:
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 5_000);
try {
const resp = await fetch(url, { signal: ctrl.signal });
} catch (err) {
if (err.name === 'AbortError') return null;
throw err;
}
AbortSignal.timeout(ms), AbortSignal.any([…]) and AbortSignal.abort() are the modern combinators.
8. Concurrency Patterns
8.1 Sleep
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
await sleep(1000);
No built-in sleep in JS — this pattern is universal.
8.2 Timeout
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
]);
}
const data = await withTimeout(fetch(url), 5_000);
AbortSignal.timeout(5_000) is the modern way — also cancels the underlying request.
8.3 Retry With Backoff
async function retry(fn, { attempts = 3, delay = 1000 } = {}) {
let lastErr;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
lastErr = err;
await sleep(delay * 2 ** i); // exponential
}
}
throw lastErr;
}
8.4 Concurrency-Limited Map
Process N items with at most K in-flight at once:
async function pMap(items, fn, concurrency) {
const results = [];
const executing = new Set();
for (const item of items) {
const p = Promise.resolve().then(() => fn(item));
results.push(p);
executing.add(p);
p.finally(() => executing.delete(p));
if (executing.size >= concurrency) await Promise.race(executing);
}
return Promise.all(results);
}
const data = await pMap(urls, fetch, 6);
Libraries: p-limit, p-map. Inline this when you don't want a dep.
9. Streams and Async Iteration
fetch response bodies, files, server-sent events are async iterables:
const resp = await fetch(url);
for await (const chunk of resp.body) {
process(chunk);
}
Build your own with async generators:
async function* tail(url) {
while (true) {
const data = await fetch(url).then(r => r.json());
yield data;
await sleep(1000);
}
}
for await (const update of tail('/api/status')) {
render(update);
}
Web Streams API (ReadableStream, WritableStream, TransformStream) is the lower-level primitive — most app code uses async iteration directly.
10. References
- MDN async/await — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function
- MDN Promise — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
- HTML spec — Event loops — https://html.spec.whatwg.org/multipage/webappapis.html#event-loops
- Jake Archibald — "Tasks, microtasks, queues and schedules" — https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
- MDN AbortController — https://developer.mozilla.org/en-US/docs/Web/API/AbortController
- See also: javascript-dom, javascript-bom-modules-and-modern-features