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 β†’