"JavaScript is single-threaded" and at the same time "JavaScript doesn't freeze while downloading data". How can both be true? The answer is the event loop, perhaps the concept that most separates junior from mid-level — because it explains execution order, async and why your UI keeps responding all at once.
Single-threaded, but non-blocking
JS executes one instruction at a time, on a single thread. If that were all, downloading a file that takes 3 seconds would freeze the entire page. The trick is that JS doesn't do that heavy work itself: it delegates to the environment (the browser) and moves on. When the work finishes, the result comes back via a queue.
The call stack
The call stack is the stack of running functions. Call a function, it gets pushed; it returns, it gets popped. This is where "single-threaded" lives: there is only one stack, and it processes one thing at a time.
Web APIs: outsourced work
When you call setTimeout, fetch or listen for a click, the browser's Web APIs take care of it — outside the JS thread. Think of a restaurant: the waiter (the thread) takes the order and hands it to the kitchen (Web API) instead of standing around cooking. He goes back to serving other tables.
Two queues: macrotasks and microtasks
When the Web API finishes, the callback doesn't run right away — it goes into a queue. And there are two:
- Task queue (macrotasks):
setTimeout, I/O events, etc. - Microtask queue: Promise
.then,queueMicrotask.
The crucial rule: microtasks have priority. After each task, the event loop drains the entire microtask queue before picking the next macrotask.
The classic ordering example
This snippet is practically an interview classic — being able to predict the output proves you understand the loop.
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// Output: 1, 4, 3, 2
Why? 1 and 4 are synchronous (they run directly on the stack). The setTimeout becomes a macrotask; the .then becomes a microtask. When the stack empties, the loop runs all microtasks (3) before touching the macrotask (2).
The loop, in a diagram
┌──────────────┐
│ Call Stack │◀── runs synchronous code
└──────┬───────┘
│ delegates
▼
┌──────────────┐
│ Web APIs │ (timer, fetch...)
└──────┬───────┘
│ done → enqueues
┌──────▼────────────────────┐
│ Microtasks (.then) ◀ high priority
│ Macrotasks (setTimeout) │
└───────────────────────────┘
Event Loop: stack empty? pull from queue
| Microtask | Macrotask | |
|---|---|---|
| Examples | Promise.then | setTimeout, events |
| Priority | High (drains everything) | One per cycle |
The event loop doesn't speed anything up — it just ensures the thread never sits idle waiting, keeping the UI alive while the heavy work happens in parallel.
Day to day this shows up when you need to understand why an await "waits" without freezing the screen, or why a heavy synchronous loop really freezes everything — it never releases the stack to let the loop breathe.
Conclusion
The stack runs synchronous code, Web APIs handle the asynchronous work, and the event loop refills the stack from the queues — microtasks first. Understanding this choreography is what lets you write async code without surprises. Once async is trivial, the next challenge is managing component state.