Concept · Runtime internals
The JavaScript
event loop
JavaScript runs your code on a single thread — and still manages to feel concurrent. The trick isn't parallelism. It's a queue, a stack, and a very disciplined scheduler.
Most explanations of the event loop start with a diagram and end with a shrug. The diagram is fine, but it tends to skip the part that actually bites you in production: ordering. Why a promise resolves before a zero-delay timer. Why a click feels laggy while a loop is running. Why your loading spinner never appears.
All of it comes from one constraint. JavaScript has exactly one call stack, and only one thing can be on top of it. Everything else — timers, network responses, clicks, promise reactions — has to wait its turn in a line. The event loop is the rule that decides whose turn it is.
One thread, one stack
When a function is called, a frame goes on the stack. When it returns, the frame comes off. That's the whole model, and it's strictly sequential: nothing interrupts a function halfway through to run something else. There is no preemption in JavaScript.
Which raises an obvious question. If nothing can interrupt, how does a setTimeout callback ever run? The answer is that setTimeout isn't part of the language. It's part of the host — the browser or Node — which happily does work on other threads and then hands results back to JavaScript through a queue.
One turn, four beats
The loop itself is almost boring. It repeats the same short routine forever, and the ordering rules you care about fall directly out of that routine.
- 01Run a taskone, to completion
- 02Drain microtasksall of them
- 03Renderif the frame is due
- 04Waitfor the next event
Two details in there do most of the work. First, a task runs to completion — the loop can't take the thread back mid-function. Second, the microtask queue is drained entirely before the loop moves on, while only one macrotask runs per turn.
Two queues, not one
Almost every confusing ordering bug is really a question of which of two lines your callback joined. They look identical from the outside and have completely different priorities.
| Source | Queue | Examples |
|---|---|---|
| Timers | Macrotask | setTimeout, setInterval |
| User input & events | Macrotask | click, scroll, keydown |
| Network & I/O | Macrotask | fetch response arriving, message |
| Promise reactions | Microtask | .then, .catch, await resumption |
| Explicit scheduling | Microtask | queueMicrotask |
| DOM observation | Microtask | MutationObserver |
Microtasks win. Always. A promise reaction queued during a task will run before a timer callback that has been waiting since before your script even started.
Watch it run
Here is the classic four-line puzzle. Step through it and watch where each callback goes — the printed order is 1, 2, 3, 4, which is not the order the code is written in.
1console.log('1: script start');2 3setTimeout(() => {4 console.log('4: timeout');5}, 0);6 7Promise.resolve().then(() => {8 console.log('3: promise');9});10 11console.log('2: script end');What just happened
Nothing has run yet. The call stack is empty and both queues are cold.
Call stack
0One thread. Top frame runs.
- empty
Microtask queue
0Drained completely, every turn.
- empty
Macrotask queue
0One per turn of the loop.
- empty
Console
- no output yet
Where it goes wrong
Once the ordering clicks, a whole category of bugs stops being mysterious. Three of them account for most of what you'll actually hit.
1. Blocking the only thread
While a task runs, nothing else can: no clicks, no scrolling, no paint. Setting a loading flag and then immediately doing heavy synchronous work means the spinner never renders — the render beat comes after your task finishes.
1setLoading(true); // state updated…2const rows = crunch(1e8); // …but the frame never painted3setLoading(false);2. Starving the loop with microtasks
Because the microtask queue is drained completely, a microtask that queues another microtask can loop forever without ever yielding. The page freezes and no timer will ever fire again.
1function spin() {2 queueMicrotask(spin); // never yields to render or timers3}4spin();3. Reading 0 as “immediately”
setTimeout(fn, 0) means “queue this as a macrotask”. The delay is a minimum, not a promise, and it competes with everything else in that queue. If you need to run after the current stack unwinds but before the browser does anything else, you want a microtask — queueMicrotask(fn). If you need to run just before paint, you want requestAnimationFrame(fn).
Takeaways
- 01Synchronous code always finishes before any callback runs.
- 02Microtasks run before the next macrotask — and the queue is drained fully, not one at a time.
- 03setTimeout(fn, 0) means “as soon as the stack is clear”, never “now”.
- 04A long synchronous function blocks input, animation, and paint. There is nowhere else for them to run.
None of this is exotic machinery. It's a stack, two queues, and a scheduler that never rushes. Once you can picture where a callback is sitting and what has to finish before it, asynchronous JavaScript stops feeling like guesswork and starts reading like a schedule.