Skip to content

How JavaScript's event loop really works

JavaScript runs on one thread but still handles clicks, network calls, and timers without freezing. Here's how the event loop, macrotasks, and microtasks make that possible.

Click a button on a page while a network request is still loading in the background, and nothing freezes. The click registers right away, an animation keeps running, and the request finishes on its own time. JavaScript pulled all of that off with one thread, running one line of code at a time.

That works because of the event loop, and it's close to how a chef runs a busy kitchen. One person, cooking one dish at a time, who still checks the oven timer, flips what's already on the stove, and starts the next order the second there's a free hand. The event loop schedules your code with that same one-at-a-time attention.

This post walks through how that scheduling works: the call stack, the macrotask queue, and the microtask queue that jumps ahead of it. Once you see the order they run in, some genuinely confusing async behavior stops being confusing.

Why a single thread doesn't freeze

Being single-threaded means JavaScript has no backup. If one function runs long, there's no second thread to pick up the slack, everything else just waits in line behind it. Get that scheduling wrong and an app stutters under load. Get it right and it stays responsive no matter how much is happening behind the scenes.

Three pieces make that possible. The call stack tracks what's running right now. Two queues hold everything else waiting for a turn, one for standard async work and one that jumps ahead of it. The call stack is where the rest of this post starts, since nothing else can run until it's empty.

The call stack

Everything in JavaScript starts with the call stack, JavaScript's record of what it's currently running.

When code calls a function, JavaScript pushes a frame for it onto the stack. When the function returns, that frame gets popped off. It works like a stack of plates. The last plate placed is the first one removed. That ordering is called LIFO, last-in-first-out.

function f(b) {
  var a = 12;
  return a + b + 35;
}

function g(x) {
  var m = 4;
  return f(m * x);
}

g(21);

Step by step:

  1. g(21) is called, a frame for g is pushed onto the stack
  2. Inside g, f is called, a new frame for f goes on top
  3. f runs, returns its value, and its frame is popped off
  4. Control returns to g, which finishes and is popped off too
  5. The stack is empty

Objects live in a separate area called the heap. If the call stack is the chef's current focus, the heap is the pantry, ingredients that sit there until a function needs them.

The stack's one-track focus is efficient but brittle. A function that runs too long blocks everything else behind it. That's called blocking the main thread, and it's the exact problem the event loop exists to solve.

The message queue and the event loop

So how does JavaScript avoid freezing during a long-running async operation?

The runtime keeps a message queue, also called the macrotask queue, a list of tasks waiting to run. When something async finishes, a click, a network response, a setTimeout firing, its callback gets added to that queue.

The event loop's job is small:

while (queue.waitForMessage()) {
  queue.processNextMessage();
}

It checks whether the call stack is empty. If it is, it pulls the oldest message off the queue and pushes that callback onto the stack to run.

Each callback runs to completion. Once a function starts, nothing interrupts it. That makes execution predictable, but it also means a slow function blocks everything downstream, which is why browsers eventually show a "page unresponsive" warning.

The queue is what makes JavaScript feel non-blocking. But not every waiting task gets equal priority. Some jump ahead of others.

Macrotasks vs. microtasks

The event loop actually runs two queues, and they don't have equal priority.

Macrotasks

Independent tasks the event loop handles one at a time, per cycle:

  • setTimeout and setInterval callbacks
  • I/O, like a network request finishing
  • User events like clicks
  • UI rendering

Microtasks

Follow-up work that needs to run right after the current code finishes. The engine drains the entire microtask queue after each macrotask, before touching the next macrotask or repainting the screen:

  • Promise handlers (.then, .catch, .finally)
  • process.nextTick in Node.js, which jumps the queue ahead of promise microtasks, more on that below
  • queueMicrotask, a lower-level API for the same kind of follow-up work. React's own effect scheduling actually avoids microtasks, using a MessageChannel-based macrotask instead, so effects run after the browser paints

Execution order

Once a macrotask finishes and the call stack empties, the engine runs every pending microtask before picking up the next macrotask. If a microtask schedules another microtask, that one runs too, before the loop moves on.

This is why a promise chain resolves before the next timer fires, even when that timer was scheduled first.

Tracing an example step by step

Here's a snippet that trips up a lot of developers. Try to predict the output before reading on.

console.log(1);
setTimeout(() => console.log(2));
Promise.resolve().then(() => console.log(3));
Promise.resolve().then(() => setTimeout(() => console.log(4)));
Promise.resolve().then(() => console.log(5));
setTimeout(() => console.log(6));
console.log(7);

Synchronous pass

  • console.log(1) runs immediately and prints 1
  • First setTimeout callback goes to the macrotask queue
  • The three Promise.then() callbacks go to the microtask queue, in the order written
  • Second setTimeout callback goes to the macrotask queue
  • console.log(7) runs immediately and prints 7

Script finishes

The call stack is empty.

  • Output so far: 1, 7
  • Microtask queue: log 3, setTimeout for 4, log 5
  • Macrotask queue: log 2, log 6

Microtask queue drains

  • First microtask runs, prints 3
  • Second microtask runs and schedules a new setTimeout, so log 4 joins the macrotask queue
  • Third microtask runs, prints 5

Output so far: 1, 7, 3, 5. Macrotask queue: log 2, log 6, log 4.

Macrotasks run one at a time

  • Log 2 runs, prints 2
  • Microtask queue is empty, so log 6 runs next, prints 6
  • Microtask queue is empty, so log 4 runs, prints 4

Final order

1, 7, 3, 5, 2, 6, 4

Microtasks beat macrotasks to the front of the line every time, regardless of which was scheduled first.

One cycle of the event loop

  1. Main script runs. It's the first macrotask, synchronous code executes and queues callbacks into both queues.
  2. Call stack empties once the script finishes.
  3. Microtask checkpoint. The engine drains the microtask queue completely, including any microtasks added during the drain.
  4. Render check. The browser may repaint here, depending on refresh rate and system load. Not guaranteed every cycle.
  5. One macrotask runs, the oldest one in the queue.
  6. Repeat from step 2.

FAQ

What's the difference between microtasks and macrotasks?

A macrotask is independent, a timer or a click event. A microtask is follow-up work, a promise handler, that runs right after the current code finishes. Every microtask runs before the next macrotask starts.

Can too many microtasks freeze the browser?

Yes. If a microtask keeps scheduling more microtasks, the event loop never leaves that queue. Rendering and every pending macrotask stay blocked until it drains, and it won't drain if the recursion doesn't stop. Watch for unbounded recursive scheduling.

Is the event loop the same in browsers and Node.js?

The concept is the same, the implementation isn't. The event loop isn't part of the V8 engine itself. Browsers build it on Web APIs for DOM events and network requests. Node.js uses libuv, a C library built for async file I/O and networking.

Node also splits microtasks further than the two-queue model above suggests. process.nextTick drains in its own queue, and that queue empties completely before Node even looks at the promise microtask queue. Mix the two in the same function and the nextTick callback wins in the typical case, regardless of which one you scheduled first.

Is the macrotask queue really just one line, first in first out?

Not quite, this post treats it that way because it's the model most people reach for first, and it holds up for everyday debugging. In reality, browsers keep separate queues for different task sources, timers, network callbacks, user input, and the spec lets them choose which source to pull from next rather than merging everything into one strict line. What stays true either way is that every microtask still drains before the next task runs, no matter which source that task came from.

Why do promises use microtasks instead of macrotasks?

So a promise callback runs as soon as possible after the current task, without waiting behind timers or click handlers already sitting in the macrotask queue. That gives promise chains a consistent order relative to everything else running.

Wrapping up

JavaScript's single thread looks like a limitation until you see the event loop managing it. Pushing async work into queues is what lets one thread handle clicks, timers, and network calls without freezing the page.

Three pieces do the work: the call stack for code running right now, the macrotask queue for standard async events, and the microtask queue for follow-ups that jump ahead of everything else. That priority order is why promise chains behave predictably.

Once this model clicks, debugging async code gets faster. You can trace exactly why a log statement fired when it did instead of guessing.