Asynchronous JavaScript
JavaScript runs on a single thread yet handles thousands of concurrent network calls. Master callbacks, Promises, async/await, and the event loop that makes it all work.
Callbacks & Callback Hell
The original async pattern: pass a function to run later, when the result is ready. It works, but nesting several of them produces the "pyramid of doom" that Promises and async/await were built to fix.
getUser(id, (user) => { getPosts(user.id, (posts) => { getComments(posts[0].id, (comments) => { console.log(comments); // three levels deep, and error handling repeats at each level }, onError); }, onError); }, onError);
Promises
A Promise represents a value that isn't ready yet. It's always in one of three states: pending, fulfilled, or rejected — and once settled, it never changes state again.
function delay(ms) { return new Promise((resolve, reject) => { setTimeout(() => resolve("done"), ms); }); } delay(1000) .then(result => { console.log(result); return delay(500); }) // chain flattens nesting .then(() => console.log("second delay done")) .catch(err => console.error(err)) // one handler, not one per step .finally(() => console.log("cleanup, runs either way"));
.then()..catch().async / await
async/await is syntax sugar over Promises — it lets asynchronous code read top-to-bottom like synchronous code, without giving up any of the non-blocking behavior underneath.
async function loadFeed(userId) { const user = await getUser(userId); // pauses here, doesn't block the thread const posts = await getPosts(user.id); const comments = await getComments(posts[0].id); return comments; // same three calls as callback hell, flat and readable } // calling an async function always returns a Promise loadFeed(1).then(comments => console.log(comments));
await is only legal inside an async function (or at the top level of a module). It suspends that function only — the rest of the program keeps running.Promise Combinators
const urls = ["/a", "/b", "/c"]; // all — fails fast: one rejection rejects everything const results = await Promise.all(urls.map(fetch)); // allSettled — always resolves, gives you each outcome const outcomes = await Promise.allSettled(urls.map(fetch)); // [{status:"fulfilled", value:...}, {status:"rejected", reason:...}, ...] // race — settles as soon as the FIRST one settles (win or lose) const fastest = await Promise.race([fetch("/a"), delay(3000)]); // any — settles on the first FULFILLED result, ignores rejections const firstOk = await Promise.any(urls.map(fetch));
| Method | Resolves when | Rejects when |
|---|---|---|
Promise.all | Every promise fulfills | Any single one rejects |
Promise.allSettled | All promises settle | Never — always resolves |
Promise.race | First one settles (either way) | If that first one rejects |
Promise.any | First one fulfills | All of them reject |
The Event Loop — Microtasks vs Macrotasks
JavaScript is single-threaded, so it needs a scheduler to interleave "waiting" work with running code — that's the event loop. It runs all synchronous code first, then drains the microtask queue completely, then processes exactly one macrotask, and repeats.
console.log("1: sync"); setTimeout(() => console.log("4: macrotask (setTimeout)"), 0); Promise.resolve().then(() => console.log("3: microtask (promise)")); console.log("2: sync"); // Output order: 1, 2, 3, 4 // All sync code runs first, THEN all microtasks, THEN the next macrotask.
Microtasks always cut in line
Promise callbacks (microtasks) always run before the next setTimeout/setInterval/I/O callback (macrotasks), even if the timeout was scheduled first with a delay of 0.
Error Handling
async function safeLoad(id) { try { const res = await fetch(`/api/users/${id}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } catch (err) { console.error("Load failed:", err.message); return null; // caller gets a safe fallback instead of a crash } }
.catch() or surrounding try/catch fires an unhandledrejection event and, in Node, can crash the process.Common Pitfalls
for (const u of urls) await fetch(u) runs one at a time. Build an array of promises and Promise.all them for real concurrency.
If a .then() callback doesn't return the next promise, the chain doesn't wait for it — it moves on immediately with the wrong value.
arr.forEach(async x => await work(x)) doesn't wait for anything — forEach ignores the returned promises. Use a for...of loop or Promise.all(arr.map(...)).
Still single-threaded. For real CPU parallelism, JavaScript needs Web Workers (browser) or worker_threads (Node) — async only overlaps I/O waits.