Front-end · Mid-level

The event loop: how JavaScript does async without freezing

"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
MicrotaskMacrotask
ExamplesPromise.thensetTimeout, events
PriorityHigh (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.

Read also

Back to track