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:
console.log("1: start"); setTimeout(() => console.log("4: timer done"), 0); Promise.resolve().then(() => console.log("3: promise done")); console.log("2: end");
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,awaitcontinuations) andqueueMicrotask. - 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
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");
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:
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");
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
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):
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); });
Loaded Ana Error: Invalid id
Callback hell
Sequential async steps with callbacks nest deeper and deeper, and every level needs its own error handling:
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.