Skip to content
Go back

Mastering the JavaScript Event Loop: A Practical Guide for Beginners

JavaScript is single-threaded, yet it somehow manages timers, network requests, and UI updates without freezing your app. The event loop is the mechanism that makes this possible.

This article walks from beginner to intermediate: call stack, Web APIs, task queues, microtasks (Promises), and how the browser/Node.js actually schedule your code.


JavaScript is single-threaded

JavaScript has one main thread of execution in the browser: at any instant it runs exactly one piece of JS code, in a single sequence. This code runs in an environment with additional features provided by the browser or Node.js, such as timers, networking, and DOM APIs.

Because only one thing can execute at a time, heavy synchronous code (like long loops or expensive computations) can block the UI and stop it from responding. The event loop exists to coordinate synchronous and asynchronous work so that the main thread stays as responsive as possible.


The call stack: where code actually runs

The call stack is a stack (LIFO) data structure the JS engine uses to track which function is currently executing.

Example:

function c() {
  console.log("c");
}

function b() {
  c();
  console.log("b");
}

function a() {
  b();
  console.log("a");
}

a();
console.log("done");

Execution flow:

  1. a() is called → a is pushed.
  2. a calls b() → b is pushed.
  3. b calls c() → c is pushed.
  4. c logs "c" and returns → c is popped.
  5. b logs "b" and returns → b is popped.
  6. a logs "a" and returns → a is popped.
  7. Global code logs "done".

Output:

c
b
a
done

Purely synchronous code follows this simple “top of the stack first” behavior.


Why we need asynchronous behavior

Real applications cannot afford to block the main thread while waiting for:

If JavaScript itself waited inside the call stack for each of these to finish, the UI would freeze and the app would feel broken.

Instead, these tasks are delegated to environment APIs:

Those APIs handle the heavy work and, when they are done, they send back callbacks to be processed later by the event loop.


The event loop at a high level

Conceptually, the event loop keeps doing this:

  1. Check the call stack.
    • If it is not empty, keep executing the top frame.
  2. If the stack is empty:
    • Process all microtasks (we will define them shortly).
    • Then take the next macrotask from the task queue and push its callback onto the stack.
  3. Repeat forever while the page or process is alive.

This loop is what connects:


Web APIs and task queues

When you call an asynchronous API, JavaScript does not keep that operation on the call stack. Instead:

  1. JavaScript registers a callback with the relevant environment API.
  2. The call stack frame returns and clears as usual.
  3. The environment does its work in the background.
  4. When it finishes, it queues the callback into a task queue.

The event loop will later move that callback from the task queue to the call stack when the stack is empty.

Example:

console.log("Start");

setTimeout(() => {
  console.log("Timeout callback");
}, 0);

console.log("End");

Step-by-step:

Output:

Start
End
Timeout callback

Even with a delay of 0, the callback is never allowed to run before the current synchronous code finishes.


Macrotasks vs microtasks

Modern JavaScript runtimes distinguish between two main categories of tasks: macrotasks and microtasks.

Macrotasks (task queue)

Macrotasks include:

The event loop takes one macrotask at a time from the task queue, runs it to completion (synchronously on the call stack), then checks microtasks, then moves on.

Microtasks (microtask queue)

Microtasks are scheduled for “as soon as possible after the current code finishes” and before the next macrotask.

Common sources:

After every macrotask finishes, the event loop will run all microtasks in the microtask queue before continuing.

This priority is crucial to understanding log order.


Classic example: Promise vs setTimeout

Consider:

console.log("A");

setTimeout(() => {
  console.log("B");
}, 0);

Promise.resolve().then(() => {
  console.log("C");
});

console.log("D");

What is the output?

  1. "A" logs (synchronous).
  2. setTimeout schedules a macrotask.
  3. Promise.resolve().then(...) schedules a microtask.
  4. "D" logs (synchronous).
  5. The call stack is now empty.
  6. Event loop checks microtask queue:
    • Runs the Promise’s .then callback → logs "C".
  7. Microtask queue is empty, event loop takes the next macrotask:
    • Runs setTimeout callback → logs "B".

Output:

A
D
C
B

The Promise callback runs before the setTimeout callback because microtasks have higher priority than macrotasks.


Visual mental model

A simplified mental model for the browser looks like this:

In pseudo-steps:

while (true) {
  if (callStack not empty) continue;

  // Run microtasks first
  while (microtaskQueue not empty) {
    callback = microtaskQueue.dequeue();
    callStack.push(callback);
    // callback runs and returns
  }

  // Then a macrotask
  if (taskQueue not empty) {
    callback = taskQueue.dequeue();
    callStack.push(callback);
    // callback runs and returns
  }

  // Render, paint, etc. can happen between iterations
}

The real implementation is more nuanced, but this model explains almost all timing behavior you encounter day to day.


Browser vs Node.js event loop

Both browsers and Node.js use an event loop, but Node.js has a more detailed phase-based loop for server workloads.

According to the Node.js docs, each loop iteration goes through several phases:

Node also has two microtask mechanisms:

process.nextTick callbacks run before Promise microtasks, and both run between phases. This makes Node timing slightly different from the browser, but the same core concepts still apply: call stack, task queues, microtasks, repeat.


Common pitfalls and how to avoid them

1. Blocking the main thread

Large loops or heavy computations keep the call stack busy and prevent the event loop from processing tasks.

Bad:

button.addEventListener("click", () => {
  // Simulate heavy work
  for (let i = 0; i < 1e9; i++) {}
  console.log("Done");
});

The UI will freeze until this finishes. Consider:

2. Assuming setTimeout(fn, 0) runs immediately

setTimeout(fn, 0) just means “run later, in a separate macrotask when the stack is clear”.

If there are microtasks or other tasks queued, your callback may run noticeably later.

3. Misunderstanding Promise order

Because microtasks run before macrotasks, Promise chains often run “earlier” than expected. Always remember:


How to get comfortable with the event loop

To internalize this, try:

Over time, the event loop becomes less “magic” and more a predictable scheduling system you can rely on.


Summary

The event loop is the core of JavaScript’s execution model:

Once you understand this flow, debugging “weird” execution order becomes much easier, and you can design smoother, non-blocking JavaScript applications with confidence.


Share this post on:

Previous Post
Mastering useActionState in React 19 - The Future of Form Handling
Next Post
Folder + index.ts: A Scalable Component Structure for Next.js