Original notes for JavaScript360.org — topic order inspired by Eloquent JavaScript (4th ed.) by Marijn Haverbeke. See the full attribution note.
Chapter 11: Asynchronous Programming
Estimated time: ~50 minutes · Chapter type: Concept
What you'll learn
- Why "slow" operations need special handling in a single-threaded language
- Callbacks, then Promises, then
async/await— the three eras of async JavaScript, and how they relate - What the event loop actually does
- Common bugs specific to asynchronous code
Why asynchronicity matters
JavaScript runs on a single thread — one line of code executes at a time, full stop. That's fine for fast operations (adding two numbers), but a real problem for slow ones: reading a file, querying a database, or fetching something over the network can take anywhere from milliseconds to seconds. If those operations blocked the thread while waiting, your entire program — a web page's UI, a server handling other users' requests — would freeze for the duration.
JavaScript's answer is asynchronous operations: you kick off the slow thing, and instead of waiting, you hand JavaScript a function to run later, once the result is ready — freeing the thread to do other work (like responding to a click, or handling another request) in the meantime.
Callbacks: the original approach
The earliest pattern for this was passing a callback function — "call this when you're done":
function fetchUserName(id, callback) {
setTimeout(() => { // setTimeout simulates something slow
callback(`User#${id}`);
}, 500);
}
fetchUserName(42, (name) => {
console.log(`Got: ${name}`);
});
console.log('This logs first — fetchUserName didn\'t block!');
Callbacks work, but they get unwieldy once you need to chain several async steps in sequence — each one nested inside the previous one's callback, a pattern nicknamed "callback hell":
fetchUser(1, (user) => {
fetchOrders(user.id, (orders) => {
fetchOrderDetails(orders[0].id, (details) => {
console.log(details); // three levels deep, and error handling gets messy fast
});
});
});
Promises
A Promise represents a value that isn't available yet but will be — either successfully (resolved) or with a failure (rejected). It's an object you can attach handlers to, rather than a function you pass in ahead of time:
function fetchUserName(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id > 0) {
resolve(`User#${id}`);
} else {
reject(new Error('Invalid id'));
}
}, 500);
});
}
fetchUserName(42)
.then((name) => console.log('Got:', name))
.catch((error) => console.error('Failed:', error.message));
The real win comes when chaining: each .then() can itself return a new Promise, and the chain flattens instead of nesting:
fetchUser(1)
.then((user) => fetchOrders(user.id))
.then((orders) => fetchOrderDetails(orders[0].id))
.then((details) => console.log(details))
.catch((error) => console.error('Something failed along the way:', error.message));
One .catch() at the end handles a failure from any step in the chain — a big improvement over manually checking for errors at every callback level.
Combining multiple promises
const results = await Promise.all([fetchUser(1), fetchUser(2), fetchUser(3)]);
// waits for all three, in parallel — fails fast if any one of them rejects
const first = await Promise.race([fetchFromMirrorA(), fetchFromMirrorB()]);
// resolves/rejects as soon as the first one settles, whichever it is
async/await
async/await is syntax that lets you write promise-based code that looks synchronous, which is dramatically easier to read for anything beyond a trivial chain. Any function marked async implicitly returns a Promise, and inside it, await pauses execution until the awaited Promise settles — without blocking the rest of the program, only that function's own progress:
async function loadOrderDetails(userId) {
const user = await fetchUser(userId);
const orders = await fetchOrders(user.id);
const details = await fetchOrderDetails(orders[0].id);
return details;
}
This is functionally equivalent to the .then() chain above, but reads top-to-bottom like ordinary code. Error handling uses ordinary try/catch, exactly as with synchronous code (Chapter 8):
async function loadOrderDetailsSafely(userId) {
try {
const user = await fetchUser(userId);
const orders = await fetchOrders(user.id);
return await fetchOrderDetails(orders[0].id);
} catch (error) {
console.error('Failed to load order details:', error.message);
return null;
}
}
async/await doesn't replace Promises — it's built directly on top of them (an async function's await expressions are just a cleaner way to consume the same Promise machinery), and you'll still reach for Promise.all directly when you need several operations to run concurrently rather than one after another.
Generators
A generator function (function*) can pause its own execution at a yield and resume later — a more general mechanism that predates, and partly inspired, async/await:
function* countUpTo(max) {
for (let i = 1; i <= max; i++) {
yield i;
}
}
for (const n of countUpTo(3)) {
console.log(n); // 1, 2, 3
}
Each call to the generator's .next() runs until the next yield, then pauses — which is exactly the "pause and resume" primitive async/await uses internally, just generalized to produce a whole sequence of values instead of a single eventual result. You won't reach for raw generators often in everyday app code, but recognizing the pattern helps demystify how async/await works under the hood.
The event loop
Here's the mechanism that actually makes all of this work. JavaScript's runtime maintains a call stack (Chapter 3) for currently-executing code, and one or more queues of callbacks waiting to run once the stack is empty. The event loop is the process that continuously checks: is the call stack empty? If so, take the next queued callback and run it.
Two important details this diagram captures:
- Promise callbacks (
.then,.catch, and code afterawait) go into the microtask queue, which the event loop always fully drains before moving on. setTimeout, DOM events, and most I/O callbacks go into the (macro)task queue, and only one of those runs per trip through the loop, after the microtask queue is empty.
That ordering explains a classic surprise:
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// Output: 1, 4, 3, 2
// — synchronous code always finishes first, then ALL microtasks, then the next macrotask
Even a setTimeout of 0 milliseconds doesn't run immediately — it still has to wait for the current synchronous code to finish and every pending microtask (including any resolved promises) to drain first.
Common asynchronous bugs
- Forgetting to
await— calling an async function withoutawaitdoesn't wait for it; you get aPromiseobject back immediately, not the resolved value, and any error inside it becomes an unhandled rejection instead of something yourtry/catchsees. - Sequential
awaitwhere parallel would do — awaiting three independent fetches one after another triples your wait time versusPromise.all([...]), which runs them concurrently. - Unhandled rejections — a rejected Promise with no
.catch()(or anasyncfunction whose error nobodyawaits inside atry/catch) fails silently in many environments, or crashes the process in others (Node.js, by default) — always handle the error path.
// Slow: sequential when it doesn't need to be
const a = await fetchA();
const b = await fetchB();
// Fast: both start immediately, running concurrently
const [a2, b2] = await Promise.all([fetchA(), fetchB()]);
Key takeaways
- JavaScript is single-threaded; async patterns let slow operations happen without freezing everything else.
- Promises represent a future value;
async/awaitis syntax sugar over Promises that reads like synchronous code. - The event loop always fully drains the microtask queue (Promise callbacks) before running the next macrotask (
setTimeout, I/O) — this explains ordering surprises. - Use
Promise.allfor independent async work that can run concurrently; reserve sequentialawaitfor steps that genuinely depend on each other's results.
Try it yourself
- Write an
asyncfunction that fetches three unrelated pieces of data concurrently usingPromise.alland returns them combined into one object. - Predict, then verify, the output order of:
console.log('a'); Promise.resolve().then(() => console.log('b')); console.log('c'); - Write a function that wraps
fetch(or thefetchUserNameexample above) with a timeout, usingPromise.raceagainst a Promise that rejects after N milliseconds.
Hints
const [x, y, z] = await Promise.all([fetchX(), fetchY(), fetchZ()]); return { x, y, z };- Synchronous code always runs before any microtask —
console.logcalls execute in the order JavaScript encounters them when nothing async blocks the line. Promise.race([actualPromise, new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms))]).
Summary
Asynchronous code is where a lot of "JavaScript feels weird" complaints actually come from, but the underlying model — a single thread, a queue of pending callbacks, an event loop that processes them in a predictable order — is logical once it clicks. Next, we put everything from the last several chapters (functions, recursion, objects, and yes, a fair bit of parsing logic reminiscent of Chapter 9) together to build something genuinely ambitious: your own small programming language.