← Back to list

Understanding the JavaScript Event Loop

For the longest time, I treated the JavaScript event loop like a black box. I knew it setTimeout didn't block; I knew promises were…

Kaleesh · 2026-08-15 05:06 · 0 claps · 5.8 min read
#javascript-event-loop #asynchronous-javascript #nodejs-event-loop #javascript #nodejs
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Understanding the JavaScript Event Loop

For the longest time, I treated the JavaScript event loop like a black box. I knew it setTimeout didn't block; I knew promises were "async," and I knew async/await made things "look synchronous." That was enough to ship code. It wasn't enough to explain code — especially the kind of output-prediction question that shows up in every serious JS interview.

What finally cracked it open for me wasn’t a diagram. It was working through actual snippets line by line and asking, at every step: what’s on the stack right now, and what’s waiting in line?

This is that walkthrough, written the way I wish someone had explained it to me.

Start with the one constraint everything else follows from

JavaScript runs on a single thread. One call stack. One thing executing at any given instant. No matter how many promises, timers, or click handlers are in flight, only one of them is actually running at any moment.

If that’s true, how does a page stay responsive while waiting on a network request? That comes down to a few distinct pieces, each doing one narrow job. Once I separated these out instead of lumping them all into “the event loop,” the rest of this made a lot more sense.

The pieces involved:

  1. Call stack — where JS actually executes code, one frame at a time. Single-threaded means only one thing runs here, ever.
  2. Web APIs / Node APIs — timers, network requests, DOM events. These run outside the JS thread (handled by the browser/Node runtime), so they don’t block the stack.
  3. Callback queues — when a Web API finishes (a timer expires, a fetch resolves), its callback gets placed in a queue, waiting for the stack to be empty.
  4. Event loop — the mechanism that keeps checking: “is the stack empty? If yes, grab the next thing from the queue.”

So the call stack is the worker, the Web/Node APIs are where the waiting actually happens (off the JS thread entirely), the queues are the holding area for work that’s ready to run, and the event loop is the dispatcher deciding what the worker picks up next. None of these do each other’s job — the stack never checks queues on its own, and the event loop never executes your code directly; it only hands work to the stack once that stack is empty.

There are actually two queues, and this is where most people — myself included — get tripped up:

  • Microtask queue — Promises (.then, .catch, async/await), queueMicrotask
  • Macrotask queuesetTimeout, setInterval, I/O, UI rendering

The rule that explains almost every “surprising” output you’ll ever see:

After every single task, the event loop drains the entire microtask queue before touching the next macrotask.

Watch what that does to ordering:

console.log("1");

setTimeout(() => console.log("2"), 0);

Promise.resolve().then(() => console.log("3"));

console.log("4");

Output: 1, 4, 3, 2

1 and 4 run synchronously — they're on the stack immediately. The setTimeout callback, even at 0ms, goes into the macrotask queue. The .then callback goes into the microtask queue. Once the stack empties, the microtask queue is drained completely first — so 3 prints — and only then does the loop check the macrotask queue, where 2 is waiting.

Promises will always jump ahead of a same-tick setTimeout, regardless of the delay value. This single fact explains a huge chunk of async interview gotchas.

Where await actually pauses (this one fooled me)

Here’s where I got it wrong the first time I traced through an example — I assumed the code after await stays "on the call stack, waiting." It doesn't. The moment execution hits await, the function suspends and hands control back immediately. Everything after that await is scheduled as a microtask right then, not later.

console.log("1");
async function foo() {
  console.log("2");
  await null;
  console.log("3");   // scheduled as a microtask HERE, the instant await runs
}

foo();

setTimeout(() => console.log("4"), 0);

Promise.resolve().then(() => console.log("5"));

console.log("6");

Output: 1, 2, 6, 3, 5, 4

Trace it:

  1. 1 prints — synchronous.
  2. foo() runs: 2 prints, hitsawait null, and at that exact point the continuation (console.log("3")) is queued as microtask #1. Control returns to the caller.
  3. setTimeout registers its callback as a macrotask.
  4. Promise.resolve().then(...) queues 5 as microtask #2 — after 3 was already queued in step 2.
  5. 6 prints — synchronous.
  6. Stack is empty → microtasks drain in the order they were queued: 3, then 5.
  7. Macrotask queue: 4.

The takeaway I keep coming back to: microtasks resolve in the order they were scheduled, not the order they appear in the source. The await inside foo() queued its continuation before the .then() line even ran, so it wins that race even though it's written above the .then() call.

Concurrency vs. parallelism

These get used interchangeably, but they’re describing different things — and the distinction is really just a staffing question.

Concurrency is one waiter juggling several tables. Take an order at table A, walk it to the kitchen, swing by table B while A’s food cooks, check if A’s plate is ready, serve it, greet table C. One person, one action at any instant, but constantly switching between tasks so nobody at any table feels ignored. This is exactly what the single-threaded event loop does — it’s not doing two things simultaneously; it’s switching between them fast enough that it looks that way.

Parallelism is multiple cooks, each one actually cooking a different dish at the same physical moment. That requires more than clever scheduling — it requires separate execution units. In JS terms: separate threads.

Plain JavaScript gives you concurrency by default and parallelism never, unless you explicitly opt in.

Opting into real parallelism: workers and clusters

This is the part that isn’t in most “event loop” explainers, but it matters once you’re past the basics.

Web Workers (browser) spin up a genuinely separate JS thread — its own call stack, its own heap — communicating back to the main thread only via postMessage. No shared memory by default. Good for CPU-heavy work (image processing, big computations) you don't want blocking the UI thread.

**worker_threads** (Node.js) is the server-side equivalent — a separate V8 instance per worker, for the same reason: offload CPU-bound work so it doesn't stall the event loop that's also trying to handle incoming requests.

Node’s thread pool (via libuv) is a subtler case. File I/O, DNS lookups, and some crypto operations get handed off to a fixed pool of OS threads under the hood — even though your JS code itself never leaves the single thread. That's why Node can juggle thousands of concurrent connections without blocking: the waiting happens off-thread, but the callback that eventually runs still lands back on the one JS thread, through the normal event loop.

Cluster is a different lever entirely — instead of parallelizing work within one process, it forks multiple copies of your entire Node process (one per CPU core, typically), each with its own event loop, sharing the same server port. It’s less “let me parallelize this one computation” and more “let me use all my CPU cores to handle more traffic.” Frameworks and platforms often do this for you (or you reach for it explicitly in a high-traffic Node service) — it’s a process-level answer to a problem workers solve at the thread level.

So the honest picture: your actual JS code is always single-threaded and concurrent. Parallelism is something you deliberately reach for — workers when you need to offload CPU work without blocking, cluster when you want to use every core to handle more load.

The full precedence order

Putting it all together, here’s the order the event loop actually follows, every single tick:

1. SYNCHRONOUS CODE (call stack)
   Runs top to bottom, immediately. Nothing else starts until this is empty.
2. MICROTASK QUEUE — drained completely before step 3
   - Promise .then / .catch / .finally
   - code after an await
   - queueMicrotask()
   (if a microtask schedules another microtask, that one runs too, before moving on)
3. RENDERING (browser only)
   The browser may paint here if a frame is due.
4. ONE MACROTASK — the oldest one, run to completion
   - setTimeout / setInterval callbacks
   - I/O completions
   - UI event handlers
   - postMessage / MessageChannel
5. Back to step 2 — and repeat, forever.

setInterval follows the same rules as setTimeout, just re-queuing itself each time — which means a backed-up microtask queue can genuinely delay or skip an interval tick. It's not immune to the same congestion everything else deals with.

Why any of this is worth knowing

None of this changes how you write a .then() chain or an async function day-to-day. What it changes is your ability to predict what code will do before you run it — which is the actual skill being tested when an interviewer hands you a snippet with console.log, setTimeoutand a promise mixed, and asks you to call the order out loud.

The mental model that finally worked for me: don’t think in terms of “async stuff happens later.” Think in terms of two queues with a strict priority between them, and a single thread that never does two things at once — only very quickly switches between them.


메타데이터
post_id
f3bbd3c5dc47
slug
understanding-the-javascript-event-loop-f3bbd3c5dc47
url
https://medium.com/@kaleeshp96/understanding-the-javascript-event-loop-f3bbd3c5dc47
canonical_url
https://medium.com/@kaleeshp96/understanding-the-javascript-event-loop-f3bbd3c5dc47
author_url
https://medium.com/@kaleeshp96
status
ok
fetched_at
2026-08-27 07:37:15