Module 5 · Asynchronous JavaScript ⏱ 19 min

The Event Loop & Callbacks

By the end of this lesson you will be able to:
  • Explain how the event loop lets single-threaded JavaScript do slow work without freezing
  • Predict the order of synchronous code, microtasks, and macrotasks such as setTimeout
  • Use callbacks, including the error-first convention, to handle async results and failures

Synchronous code runs one line at a time: finish the first, then the next, then the next. That is easy to follow, and it becomes a real defect the moment a single operation has to wait. Suppose your code asks a database for a record and the reply takes half a second. On a single thread that half second freezes everything behind it, so no click registers, no animation steps, and the whole page looks hung.

JavaScript runs on one thread, which means any statement that blocks blocks the entire program. The way out is asynchronous code: start a slow job, keep doing other useful work while it runs, and handle the answer when it arrives. This lesson introduces the machinery that makes that possible, the event loop, and the original tool for driving it, the callback.

flowchart TD
  A["synchronous code runs on the call stack"] --> B["a slow job is handed to an async API"]
  B --> C["JavaScript keeps running the lines after it"]
  C --> D["the finished task callback enters a queue"]
  D --> E["the event loop runs it once the stack is clear"]
  style A fill:#3776ab,color:#fff
  style D fill:#b45309,color:#fff
The event loop: synchronous code runs on the call stack; finished async tasks wait their turn and run only when the stack is empty.

What async does, and does not, fix

It is worth being precise about the slowness async actually addresses. Waiting on a reply from a database, a file, or the network is called I/O, and during that wait the thread is simply idle, doing nothing, which is the ideal gap for async code to fill with other work. A genuinely heavy calculation is different: it truly occupies the thread, and making it async will not make it run faster. Asynchronous programming is about waiting well, not about computing more quickly.

One thread, but never idle

The call stack is how JavaScript remembers where it is. The function currently running sits on top of the stack; when it finishes, control drops back to whoever called it. Everything that sits on the stack is the synchronous code executing right now.

Async work is kept off the stack. When you pass a job to an async API such as setTimeout, the engine records it and immediately continues your function instead of standing still. When the job completes, the function you wanted to run afterward is placed in a queue, not executed at once. So the thread is single, but it is rarely idle: while one job waits, the engine is free to run other code, which is exactly why an interface stays responsive during slow work.

The two console.log calls run first; the timeout fires afterwards even at a delay of zero.
console.log("1: start");

setTimeout(() => {
  console.log("3: timeout fired");
}, 0);

console.log("2: end of script");

Two queues, one important rule

Finished async work lands in one of two queues. Macrotasks, such as a setTimeout callback or a DOM event handler, wait in the task queue. Microtasks, such as the callback of a resolved Promise, wait in a separate, faster queue. Both are drained only when the call stack is empty.

The rule that explains every surprising print order is short: between any two macrotasks, the engine clears the entire microtask queue first. That is why a resolved Promise runs before a setTimeout set to zero, even though both are already done. Hold onto that rule; the quiz leans on it, and it underpins everything in the next two lessons.

flowchart LR
  S["call stack empty"] --> M["drain ALL microtasks<br/>(resolved Promises)"]
  M --> T["run ONE macrotask<br/>(timeout, event)"]
  T --> S
  style M fill:#1e7f3d,color:#fff
  style T fill:#b45309,color:#fff
Microtasks drain fully between macrotasks, so a resolved Promise runs before a zero-delay timeout.

Callbacks: 'call me back when you are done'

The oldest way to receive an async result is a callback: a function you pass to another function, to be invoked later with the answer. You have already used callbacks under another name, because map, filter, and forEach each accept a function they call once per element.

The same pattern covers genuinely async work. You call a function and hand it a second function; when the slow job finishes, that function invokes your callback and passes it the result. The mental shift is about control: you do not wait for the answer, you arrange to be told. Nothing in the language forces the caller to actually invoke your callback, to invoke it exactly once, or to hand it the right arguments, which is why strong conventions grew up around them.

A callback-based function does its work, then calls your function with the result.
function loadUser(id, cb) {
  // pretend this takes time; here it answers at once
  cb({ id: id, name: "Ada", role: "admin" });
}

loadUser(7, (user) => {
  console.log("loaded " + user.name + " as " + user.role);
});

// the callback can do anything a normal function can
loadUser(7, (user) => {
  console.log(user.id + ": " + user.name.toUpperCase());
});

The error-first convention

Because a callback has no built-in way to signal failure, the Node.js community settled on the error-first style: the callback's first parameter is reserved for an error and is null when nothing went wrong, while the real result comes second. Writing if (err) as the first line of every callback became muscle memory.

A single callback is manageable. Trouble arrives when the second async step needs the first step's result, so you nest the second call inside the first callback, the third inside the second, and the shape curls into a rightward triangle nicknamed callback hell. Each level wants its own error check, so three steps mean three if (err) guards, and skipping one lets a failure vanish without a trace. That pain is exactly what Promises were invented to remove.

flowchart LR
  A["loadUser(7, onDone)"] --> B["loadUser does its work"]
  B --> C["onDone(null, user)"]
  B --> D["onDone(error)"]
  style C fill:#1e7f3d,color:#fff
  style D fill:#b91c1c,color:#fff
Error-first callback: on success the error is null and the result follows; on failure only the error is set.
Always inspect the error argument first; only touch the result when it is null.
function findUser(id, cb) {
  if (id === 99) {
    cb(new Error("user 99 not found"));
  } else {
    cb(null, { id: id, name: "Ada" });
  }
}

findUser(7, (err, user) => {
  if (err) {
    console.log("error: " + err.message);
  } else {
    console.log("ok: " + user.name);
  }
});

findUser(99, (err, user) => {
  if (err) {
    console.log("error: " + err.message);
  } else {
    console.log("ok: " + user.name);
  }
});
Exercise

What does this print? The callback is invoked immediately, so trace what value it receives.

function twice(n, cb) {
  cb(n * 2);
}

twice(5, (result) => console.log(result));
Exercise

Write greet(name, cb) that calls the callback cb with the string "hello, " + name. The function does the work by invoking cb; it does not need to return anything.

function greet(name, cb) {
  // call cb with the greeting string
}
Exercise

Write parseScore(input, cb) using the error-first convention. If input is a number, call cb(null, input). Otherwise call cb(new Error("not a number"), null).

function parseScore(input, cb) {
  // error-first: cb(null, value) on success, cb(error, null) on failure
}
Exercise

This error-first function is meant to hand the callback a user object, but the author swapped the order and called cb(user, null) instead of cb(null, user). So a caller reading the first argument as the error sees the user object, not null. Swap the arguments so the error comes first.

function loadName(id, cb) {
  cb({ name: "Ada" }, null);
}
Exercise

You run this code. In what order do the lines print?

console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");

Recap

  • JavaScript runs on a single thread; a blocking statement freezes everything behind it, so slow work has to be asynchronous.
  • The call stack holds running code; finished async tasks wait in queues and run only when the stack is empty.
  • Microtasks (resolved Promises) drain fully between macrotasks (setTimeout, events), which is why a resolved Promise beats a zero-delay timeout.
  • A callback is a function passed in to be called later with the result; the error-first convention reserves the first argument for an error.
  • Nested callbacks curl into callback hell, the problem Promises and async/await were built to solve.

Next you will meet Promises, a dedicated object that turns 'arrange to be told' into a value you can actually chain.

Checkpoint quiz

What prints LAST when this runs: console.log("A"); setTimeout(() => console.log("B"), 0); console.log("C");?

In the error-first callback convention, what does the FIRST argument to the callback represent?

Go deeper — technical resources