Skip to main content
Adapted, not copied

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 after await) 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 without await doesn't wait for it; you get a Promise object back immediately, not the resolved value, and any error inside it becomes an unhandled rejection instead of something your try/catch sees.
  • Sequential await where parallel would do — awaiting three independent fetches one after another triples your wait time versus Promise.all([...]), which runs them concurrently.
  • Unhandled rejections — a rejected Promise with no .catch() (or an async function whose error nobody awaits inside a try/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/await is 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.all for independent async work that can run concurrently; reserve sequential await for steps that genuinely depend on each other's results.

Try it yourself

  1. Write an async function that fetches three unrelated pieces of data concurrently using Promise.all and returns them combined into one object.
  2. Predict, then verify, the output order of: console.log('a'); Promise.resolve().then(() => console.log('b')); console.log('c');
  3. Write a function that wraps fetch (or the fetchUserName example above) with a timeout, using Promise.race against a Promise that rejects after N milliseconds.
Hints
  1. const [x, y, z] = await Promise.all([fetchX(), fetchY(), fetchZ()]); return { x, y, z };
  2. Synchronous code always runs before any microtask — console.log calls execute in the order JavaScript encounters them when nothing async blocks the line.
  3. 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.

 

🌟 Join the JavaScript360 community

Connect with fellow JS developers, and get free interview prep, React challenges, and DSA guides.

Join on WhatsApp →