Event Loop & Call Stack in JavaScript

Why should you care about Event Loop & Call Stack in JavaScript?

Understanding event loop behavior helps you predict async output correctly and avoid race-condition bugs. This is the key to writing reliable code with promises, timers, and async/await.

The call stack is where JavaScript keeps track of what function is currently running. Every time you call a function, it gets added (pushed) to the top of the stack. When that function finishes, it gets removed (popped) from the stack.

JavaScript code in a browser is generally executed on a single main thread, so only one piece of JavaScript runs at a time on that thread. The call stack follows LIFO - Last In, First Out: the last call added is the first call removed.

javascript

function greet() {
  console.log("Hello!");
}

function sayGoodbye() {
  console.log("Goodbye!");
}

function main() {
  greet();       // greet() pushed onto stack, runs, then popped
  sayGoodbye();  // sayGoodbye() pushed, runs, then popped
}

main(); // main() is pushed first

// Call stack steps:
// 1. main()       -> pushed
// 2. greet()      -> pushed on top of main()
// 3. greet()      -> popped (finished)
// 4. sayGoodbye() -> pushed on top of main()
// 5. sayGoodbye() -> popped (finished)
// 6. main()       -> popped (finished)

When functions call other functions, the stack grows. JavaScript executes synchronous code on the call stack, and a currently running function must return before the next synchronous operation can continue. If the stack gets too deep through recursion, you get the Maximum call stack size exceeded error.

javascript

function a() {
  b();
}

function b() {
  c();
}

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

a();
// Stack at the deepest point: a() -> b() -> c()
// c() runs, then c is popped, then b is popped, then a is popped

JavaScript executes synchronous code on the call stack. In a browser, timers, network requests, and user events can be handled by browser facilities while JavaScript continues running other code.

The event loop coordinates when queued tasks and microtasks can run after the current JavaScript execution completes. It does not perform the asynchronous work itself.

javascript

console.log("1 - Start");

setTimeout(function() {
  console.log("3 - Inside setTimeout");
}, 0);

console.log("2 - End");

// Output:
// 1 - Start
// 2 - End
// 3 - Inside setTimeout

Even though the delay is 0, the setTimeout callback does not run immediately. The browser handles the timer while JavaScript continues executing synchronous code. When the timer becomes eligible, its callback is queued for later execution and can run after the current task and its microtasks finish.

Web APIs are capabilities provided by the browser environment, not by the JavaScript language itself. Things like setTimeout, fetch, addEventListener, and XMLHttpRequest are browser APIs that work alongside the JavaScript engine. Other JavaScript runtimes can provide different APIs.

When you call setTimeout, JavaScript hands the timer off to the browser and immediately moves on. The browser counts the time in the background. JavaScript does not wait - it keeps running the next lines of code. This is what makes JavaScript non-blocking.

javascript

console.log("Before fetch");

// fetch() is a Web API - handed off to the browser
fetch("https://api.example.com/data")
  .then(function(response) {
    return response.json();
  })
  .then(function(data) {
    console.log("Data received:", data);
  });

console.log("After fetch - this runs before data arrives");

// Output order:
// Before fetch
// After fetch - this runs before data arrives
// Data received: { ... }  -> arrives later via a Promise handler

Notice that "After fetch" prints before the data arrives. fetch() returns a Promise, and the browser performs the network operation while JavaScript continues executing synchronous code. When the Promise settles, its .then() handlers run as microtasks.

  • setTimeout / setInterval - timer functions handled by the browser
  • fetch / XMLHttpRequest - network requests handled by the browser
  • addEventListener - DOM event handling
  • geolocation, localStorage, IndexedDB - browser storage and device APIs

When a browser task such as a timer becomes ready, its callback becomes eligible to run and is placed in the Callback Queue, also commonly called the Task Queue or Macrotask Queue.

The event loop coordinates when an eligible task can run. The current task must finish, and the microtask queue must be drained, before the browser proceeds to another task.

javascript

console.log("A");

setTimeout(function() {
  console.log("C - from callback queue");
}, 1000);

setTimeout(function() {
  console.log("D - also from callback queue");
}, 500);

console.log("B");

// Output:
// A
// B
// D - also from callback queue  (500ms fires first)
// C - from callback queue       (1000ms fires second)

Both setTimeout callbacks become eligible after their delays. A delay is a minimum wait, not a guarantee that the callback runs at that exact time. In this example, the 500ms timer becomes eligible before the 1000ms timer, but each callback still waits for the call stack and earlier queued work to clear.

Promises use a separate queue called the microtask queue. In the simplified browser model, after the current task finishes, the microtask queue is drained before the event loop proceeds to the next task.

This means Promise .then(), .catch(), and .finally() handlers that are ready usually run before a ready setTimeout task in the next task turn.

javascript

console.log("1 - Start");

setTimeout(function() {
  console.log("4 - setTimeout callback");
}, 0);

Promise.resolve().then(function() {
  console.log("3 - Promise .then()");
});

console.log("2 - End");

// Output:
// 1 - Start
// 2 - End
// 3 - Promise .then()    -> microtask runs before setTimeout
// 4 - setTimeout callback

Even though setTimeout has a delay of 0, the Promise .then() runs first in this browser example. The Promise handler is a microtask, and the microtask queue is drained before the browser proceeds to the next task.

Other things that go into the microtask queue:

  • Promise .then(), .catch(), and .finally()
  • async/await - it is built on Promises, so await resumes inside the microtask queue
  • queueMicrotask() - explicitly schedules a microtask
  • MutationObserver callbacks

Now let us put it all together. In the common browser model, the current task runs first, microtasks are drained next, and the event loop then proceeds to another eligible task.

Interactive walkthrough: step through a synchronous log, a Promise microtask, and a timer callback. The event loop drains microtasks before it takes the next callback task.

Call stack
Ready to run
Microtask queue
Empty
Callback task queue
Empty

Step 1: synchronous code begins on the call stack.

Output: 1 - Sync: Start

  • 1. Current task - synchronous JavaScript runs on the call stack
  • 2. Microtask queue - Promise callbacks run next, after the current task finishes but before any macrotasks
  • 3. Callback queue (macrotask queue) - the event loop proceeds to another eligible timer, event, or other task

This is exactly what interviewers love to ask about. If you understand this order, you can predict the output of any async JavaScript snippet.

javascript

console.log("1 - Sync: Start");

setTimeout(function() {
  console.log("5 - Macrotask: setTimeout");
}, 0);

Promise.resolve().then(function() {
  console.log("3 - Microtask: Promise 1");
}).then(function() {
  console.log("4 - Microtask: Promise 2");
});

console.log("2 - Sync: End");

// Output:
// 1 - Sync: Start
// 2 - Sync: End
// 3 - Microtask: Promise 1
// 4 - Microtask: Promise 2
// 5 - Macrotask: setTimeout

Step by step, here is what happens:

  • Step 1: console.log("1 - Sync: Start") runs immediately - it is synchronous
  • Step 2: setTimeout is handed off to the browser. A delay of 0 means the callback becomes eligible after the timer expires; it does not mean "run immediately"
  • Step 3: Promise.resolve().then(...) schedules the first .then() in the microtask queue
  • Step 4: console.log("2 - Sync: End") runs - still synchronous
  • Step 5: The call stack is now empty. JavaScript drains the microtask queue - Promise 1 runs, which chains Promise 2, which also runs
  • Step 6: The microtask queue is empty. Now the event loop picks up the setTimeout callback from the callback queue

This is a common browser model for understanding tasks and microtasks. Real scheduling also involves rendering and runtime-specific details, so it is not an absolute rule for every JavaScript runtime.

  • Call Stack: JavaScript code in a browser generally runs on one main thread. The call stack tracks active function calls using LIFO order.
  • Single-threaded execution: Only one piece of JavaScript runs at a time on that thread. Long-running synchronous code blocks other JavaScript work on it.
  • Web APIs: Features like setTimeout and fetch are provided by the browser, not JavaScript itself. They can handle work outside the JavaScript execution thread.
  • Task Queue: When a browser operation makes a callback eligible, it can be placed in the Callback Queue, also called the Task or Macrotask Queue.
  • Microtask Queue: Promises and async/await use the microtask queue. After the current task, ready microtasks are drained before the next task in the common browser model.
  • Typical execution order: The current task, then microtasks, then another eligible task. Timers, rendering, and runtime scheduling conditions can affect the details.
  • Event Loop: The event loop coordinates when queued tasks and microtasks can run after the current JavaScript execution completes.
What's next? Head over to the Execution Context tutorial to understand how JavaScript sets up the environment for your code to run.

Videos for this topic will be added soon.

Reviewed by

SimplyJavaScript Editorial Team

Technical editors and JavaScript educators with hands-on experience building frontend projects, writing learning material, and reviewing tutorials for clarity, accuracy, and beginner-friendly guidance.