The Event Loop & Asynchronous Code

JavaScript runs on a single thread — it can only do one thing at a time — yet pages stay responsive while waiting for network requests, timers and user input. The secret is the event loop. Understanding it explains why asynchronous code runs in the order it does.

Synchronous vs asynchronous

Synchronous code runs line by line; each line finishes before the next starts. Asynchronous operations — timers, network requests, reading files — are started now and finish later, without blocking the code that follows:

JS order.js
console.log("1: start");

setTimeout(() => console.log("4: timer done"), 0);

Promise.resolve().then(() => console.log("3: promise done"));

console.log("2: end");
Output
1: start
2: end
3: promise done
4: timer done

Even with no delay, both callbacks wait until the main code finishes — and the promise callback beats the timer. Here's why.

The pieces

  • Call stack — the functions currently running. JavaScript executes whatever is on top until the stack is empty.
  • Web APIs / runtime — the browser (or Node.js) handles timers, network requests and events outside the JavaScript thread.
  • Task queue (macrotasks) — callbacks from timers, events and I/O waiting to run.
  • Microtask queue — promise callbacks (then, await continuations) and queueMicrotask.
  • Event loop — whenever the call stack is empty, it runs all microtasks, then takes one task, then repeats (rendering the page in between when needed).

So in the example above: the main script is the first task. When it ends, the microtask queue (the promise) is drained, then the next task (the timer) runs.

Microtasks before tasks

JS micro-macro.js
setTimeout(() => console.log("timeout 1"), 0);
setTimeout(() => console.log("timeout 2"), 0);

Promise.resolve()
  .then(() => console.log("micro 1"))
  .then(() => console.log("micro 2"));

queueMicrotask(() => console.log("micro 3"));
console.log("sync");
Output
sync
micro 1
micro 3
micro 2
timeout 1
timeout 2

All microtasks — including ones queued by other microtasks, like "micro 2" — run before the next timer gets a turn. ("micro 2" comes after "micro 3" because it was only queued once "micro 1" finished.)

Blocking the thread

Because there's one thread, long-running synchronous code freezes everything: clicks aren't handled, animations stop, timers are delayed:

JS blocking.js
const start = Date.now();
setTimeout(() => console.log(`timer ran after ${Date.now() - start >= 200 ? "200+" : "<200"}ms`), 10);

while (Date.now() - start < 200) {
  // busy-wait for 200ms, blocking everything
}
console.log("loop finished");
Output
loop finished
timer ran after 200+ms

The timer asked for 10 ms but had to wait 200 ms for the loop to end. In a browser, the page would be completely unresponsive during that time. Remedies for heavy work: split it into chunks with setTimeout or scheduler.yield(), or move it to a Web Worker, which runs JavaScript on a separate thread.

See it in the browser

Try it yourself
Result

Clicks on "Count" during the freeze are queued as tasks and handled all at once when the thread is free again.

Callbacks: the original async pattern

Before promises, async functions took a callback to call when finished — typically with an error as the first argument (the "error-first" convention, common in older Node.js code):

JS callbacks.js
function loadUser(id, callback) {
  setTimeout(() => {
    if (id <= 0) callback(new Error("Invalid id"));
    else callback(null, { id, name: "Ana" });
  }, 10);
}

loadUser(1, (err, user) => {
  if (err) return console.log("Error:", err.message);
  console.log("Loaded", user.name);
});
loadUser(0, (err, user) => {
  if (err) return console.log("Error:", err.message);
  console.log("Loaded", user.name);
});
Output
Loaded Ana
Error: Invalid id

Callback hell

Sequential async steps with callbacks nest deeper and deeper, and every level needs its own error handling:

JS pyramid.js
getUser(id, (err, user) => {
  if (err) return handle(err);
  getOrders(user, (err, orders) => {
    if (err) return handle(err);
    getInvoice(orders[0], (err, invoice) => {
      if (err) return handle(err);
      sendEmail(user, invoice, err => {
        if (err) return handle(err);
        console.log("done");
      });
    });
  });
});

Promises flatten this into a chain, and async/await makes it look like ordinary synchronous code. The next two lessons cover both.