Asynchronous JavaScript

Lesson 1 of 9

The Event Loop

"Concurrency is not parallelism." — Rob Pike

Part II left you with a clear, almost comforting picture: JavaScript has one call stack and does exactly one thing at a time. Functions push on, run, and pop off, top to bottom, in order. It is a model you can trace with a finger. But now ask the obvious question that model can't answer: if there is only one stack and one thread, how does a browser fetch data over a slow network, run animations, respond to your clicks, and keep everything smooth — all seemingly at once? A single thread that could only do one thing at a time would freeze solid the instant it waited on anything. The answer — the mechanism that makes JavaScript feel alive without a second thread — is the event loop. This is the beating heart of asynchronous JavaScript, and it opens Part III.

The question this chapter answers

JavaScript runs on one thread and one call stack — so how does it wait for a network request, a timer, or a click without freezing everything else in the meantime?

Core Concepts

JavaScript's runtime is single-threaded: one call stack, one thing at a time. That part of Part II is still true and always will be. Your code never runs "in parallel" with other JavaScript. What changes here is everything around the stack. The trick is that the language doesn't work alone — it lives inside a host (a browser, or Node.js) that hands it superpowers the stack itself doesn't have.

The pieces on the table

Four pieces work together. Get these straight and the rest is inevitable:

Piece What it is Who owns it
Call stack Where your synchronous code actually runs, one frame at a time The JS engine
Web / host APIs Timers, network (fetch), DOM events — started by JS but run outside the stack The browser / Node
Task queue (macrotasks) A line of callbacks waiting for their turn on the stack The host
Microtask queue A separate, higher-priority line — where promise callbacks wait The host

When you call setTimeout(fn, 1000), the timer does not run on the call stack. You hand fn and the delay to a host API, which starts counting outside JavaScript entirely, and your stack keeps going. When the timer finishes, the host doesn't barge in — it can't, the stack might be busy. Instead it politely places fn at the back of the task queue and waits.

Everyday analogy

A short-order cook (the single thread) works one dish at a time at the counter (the call stack). When an order needs something slow — bread in the toaster, a kettle to boil (the host APIs) — the cook doesn't stand there staring at the toaster. They start it, put a ticket on the rail (the queue), and get on with the next dish. When the toaster pops, its ticket is waiting in line — the cook finishes the dish in hand before picking it up. One cook, no idle staring, nothing burnt. That is the event loop.

The event loop algorithm

The event loop is a tiny, tireless supervisor running this exact cycle, forever:

  1. Run the call stack to empty. Execute all synchronous code until nothing is left on the stack.
  2. Drain the entire microtask queue. Run every microtask — and any microtasks those schedule — until the queue is completely empty.
  3. (Browser only) Render if it's time to paint.
  4. Take exactly ONE macrotask from the task queue, push it onto the stack, and go back to step 1.

The rule that trips people up lives in steps 2 and 4: all microtasks are drained before the next single macrotask runs. Microtasks jump the queue. A promise callback will always run before a setTimeout callback that was scheduled first, because promises live in the priority lane.

The engineer's notebook

The mental model that makes async predictable:

• Synchronous code always finishes first. Nothing async runs until the stack is empty — full stop.
• Then microtasks, all of them. Promises (.then, await continuations, queueMicrotask) drain completely before the loop moves on.
• Then one macrotask, and repeat. Timers, I/O callbacks, and events take turns one at a time, with a full microtask drain between each.
• "Async" ≠ "another thread." Your JS still runs one line at a time. The waiting happens elsewhere; only the callbacks come back to your one thread.

Why setTimeout(fn, 0) doesn't run immediately

This surprises everyone once. setTimeout(fn, 0) reads like "run fn now," but it means "run fn as soon as possible after the stack is clear — and after any pending microtasks." Zero is a minimum delay, not a promise of immediacy. fn becomes a macrotask, and macrotasks wait their turn at the very back of the loop.

The canonical ordering demo

Here is the one demo every JavaScript engineer must be able to predict cold. Read it, guess the output order, then run it in the editor:

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

Trace it against the algorithm. "1" and "4" are synchronous — they run first, in order, because the stack must empty before anything async happens. Then the loop drains microtasks: the promise callback prints "3". Only then does it take one macrotask — the timer — printing "2". The setTimeout was scheduled before the promise, yet it runs after it. That's the microtask priority lane in action.

To prove microtasks drain completely first — even ones created mid-flush — watch what happens when a microtask schedules another microtask:

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

promise B was created while the microtask queue was being drained, and it still runs before the timer. The loop refuses to touch a macrotask until the microtask queue is genuinely empty. Chain a thousand promises and they all run before one setTimeout(…, 0).

History changed here

For years the event loop was folklore — something you absorbed by breaking things. Then in 2014 Philip Roberts gave a JSConf EU talk, "What the heck is the event loop anyway?", and drew the call stack, the queue, and the loop as moving boxes. It became one of the most-watched programming talks ever made, precisely because it turned an invisible mechanism into something you could see. The lesson that outlived the talk: the event loop was never magic. It's a queue and a rule, and once you can picture the boxes, async stops being scary.

Why blocking the loop is a catastrophe

Everything above rests on one fragile assumption: the call stack empties quickly. The event loop can't drain microtasks, can't render, can't pick up the next click — it can't do anything — while a synchronous function is still running. So if you run a heavy for loop that takes three seconds, for those three seconds the page is frozen. Buttons don't respond. Animations stop. On a server, that thread stops answering every other request. This is why "don't block the event loop" is the first commandment of both browser and Node performance.

The fix is not "add a thread" — you don't have one. The fix is to break long work into pieces (yielding to the loop between chunks so it can breathe) or move it off the main thread (a Web Worker in the browser, a worker thread or child process in Node). Either way, the goal is the same: never let one task hog the one stack.

Real-world example — chika.ng Academy

When a student submits a quiz, the Academy's server must grade it, write the attempt to MySQL, and maybe generate a certificate. If the request handler did all of that synchronously on one tick, that Node thread couldn't answer a single other student until it finished. Instead the database and file writes are awaited — they run as host I/O off the stack, and their callbacks return as microtasks — so while one student's certificate is being written, the same thread is already serving the next student's page. One thread, hundreds of learners, nobody waiting on anybody else. That's the event loop earning its keep.

Common Mistakes

  • Thinking setTimeout(fn, 0) runs "immediately." It's a macrotask; it waits for the stack to clear and for all microtasks to drain. It is the last thing in the demo, not the first.
  • Assuming async means multi-threaded. JavaScript still runs one line at a time. The concurrency comes from waiting elsewhere, not from extra threads.
  • Blocking the loop with heavy synchronous work. A giant loop or a huge JSON.parse freezes the UI or stalls the server. Chunk it or offload it.
  • Expecting a timer's delay to be exact. setTimeout(fn, 100) means "no sooner than 100ms." If the stack is busy or the queue is long, it runs later — the delay is a floor, not a guarantee.
  • Forgetting microtasks can starve the loop. An endless chain of promises that keeps scheduling more microtasks will block rendering and macrotasks forever — the queue never empties, so step 3 never happens.
Thought experiment

You write while (Date.now() < start + 5000) {} — a loop that spins for five seconds doing nothing — and just before it you called setTimeout(() => console.log("done"), 1000). The timer's host counter finishes after 1 second and drops its callback into the task queue, ready and waiting. So does "done" print at the 1-second mark? No. The callback sits in the queue, but the call stack is still busy spinning. It can't run until the while loop finishes at 5 seconds and the stack empties. The timer was "ready" for four full seconds and couldn't do a thing — because the one thread was held hostage. That gap between ready and running is the whole reason you must never block the loop.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Junior
"JavaScript is single-threaded. So how can it do things asynchronously?"
Model answer — "The JS engine runs one thing at a time on the call stack, but it lives inside a host — the browser or Node — that provides APIs like timers and fetch. Those run outside the stack. When they finish, their callbacks are queued, and the event loop pushes them onto the stack once it's empty. So the waiting happens off-thread; only the callbacks run on our one thread."
Follow-up they'll throw: "Does setTimeout(fn, 0) run fn immediately?" → No. It's a macrotask that waits for the stack to clear and all microtasks to drain first.
Intermediate
"Walk me through the output order of a script with a console.log, a setTimeout(…, 0), and a Promise.resolve().then(…)."
Model answer — "Synchronous logs first, in order. Then the event loop drains the entire microtask queue — so the promise callback runs next. Only then does it take one macrotask, the timer. So it's sync → promise → timeout, even though the timeout was scheduled before the promise. Microtasks have priority and are fully drained between each macrotask."
Follow-up: "What if the promise callback schedules another promise?" → It still runs before the timer — microtasks created mid-drain are drained too, before any macrotask.
Senior
"A Node service's p99 latency spikes under load even though CPU isn't maxed. How does the event loop explain it, and how do you fix it?"
Model answer — "A single slow synchronous operation on the main thread — a big sync crypto call, a huge JSON.parse, a tight loop — blocks the event loop. While it runs, every other request's callback sits in the queue waiting, so latency climbs even though average CPU looks fine: it's a queuing problem, not a throughput one. I'd measure event-loop lag, find the blocking call, and either make it async, chunk it to yield between iterations, or offload it to a worker thread. The principle is constant: keep the main thread's tasks short so the loop keeps turning."
The senior tell: framing it as head-of-line blocking on one shared thread, then reaching for measurement (event-loop lag) before reaching for a fix.
Company lens — how the same topic gets asked
micro1Live and conversational — the AI interviewer wants you to narrate the loop as you trace an ordering snippet. Say "stack empties, then microtasks, then one macrotask" out loud; thinking aloud beats a silent correct guess.
GoogleWants precision on the queue split: microtask vs macrotask, and exactly when each drains. Expect a tricky ordering snippet with nested promises and timers.
MicrosoftValues a clean mental model explained well — draw or describe the four pieces and the four-step loop, in order.
Amazon / AWSOperational lens — "what happens to other requests when one handler blocks the loop?" Tie it to latency, head-of-line blocking, and worker threads.
Hands-on exercise
  1. In the editor above, add a second setTimeout(() => console.log("2b"), 0) and a second Promise.resolve().then(() => console.log("3b")). Predict the full order before running, then confirm it.
  2. Write a snippet where a setTimeout callback itself schedules a Promise.resolve().then(…). Predict whether that promise runs before or after a second, already-queued setTimeout — then run it and explain what you saw.
  3. Out loud (as in a micro1 screen), explain in one breath why setTimeout(fn, 0) can still take five seconds to fire.

Chapter Summary

  • JavaScript is single-threaded: one call stack, one thing at a time. Async does not add threads.
  • The host (browser/Node) provides Web APIs — timers, network, DOM events — that run outside the stack and queue their callbacks when done.
  • The event loop's cycle: run the stack to empty → drain all microtasks → (render) → take one macrotask → repeat.
  • Microtasks (promise callbacks) have priority and drain completely before the next macrotask; timers and events are macrotasks.
  • setTimeout(fn, 0) is a macrotask — it waits for the stack to clear and microtasks to drain, so it runs last, and its delay is a floor, not a guarantee.
  • Blocking the loop with long synchronous work freezes the UI or stalls the server; the fix is to chunk the work or move it off the main thread.
Narrative bridge

You now know where deferred work goes and when the loop runs it. But something has to describe what to run when the waiting is over — a function you hand off to be called back later. That pattern is the oldest tool in async JavaScript, and for a long time it was the only one. It's also how a generation of developers built pyramids of doom. Next: Callbacks & Callback Hell — the good idea that nesting turned into a nightmare, and why the whole rest of Part III exists to escape it.

Editorial reflection — the event loop is the moment JavaScript stops being a list of instructions and becomes a system that lives in time. Most bugs that make people say "JavaScript is weird" are really just the loop doing exactly what it promised — sync first, microtasks next, one macrotask at a time — while the developer expected the world to pause and wait for them. It never does. Learn to see the loop turning behind your code, and async stops being a source of surprise and becomes a source of control.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.