How JavaScript Works in the Browser Explained

Learn how JavaScript engines, the call stack, event loop, and task queues work together to run code in the browser. Understand async execution and why JavaScript is single-threaded.

7 min read

How JavaScript works in the browser starts with one surprising fact: it runs on a single thread and does one thing at a time. Yet it handles user clicks, network requests, timers, and animations, all apparently at once. How?

The answer is the JavaScript runtime model: a combination of the call stack, Web APIs, task queues, and the event loop.

The Pieces of the Runtime

JavaScript runtime in the browser

Each piece has a specific job. Let us go through them.

The Call Stack

The call stack is a data structure that tracks which function is currently running. When you call a function, it goes on top of the stack. When it returns, it comes off.

javascriptjavascript
function multiply(a, b) {
  return a * b;
}
 
function square(n) {
  return multiply(n, n);
}

Each function calls the next, building up the stack one frame at a time. The outermost function reads the final result and prints it to the console:

javascriptjavascript
function printSquare(n) {
  const result = square(n);
  console.log(result);
}
 
printSquare(4);

Here is what happens on the call stack:

printSquare(4) is pushed

It goes onto the top of the stack.

square(4) is pushed

printSquare calls square, adding another frame.

multiply(4, 4) is pushed

square calls multiply, adding a third frame.

multiply returns and pops

It returns 16 and comes off the stack.

square returns and pops

It returns 16 and comes off the stack.

console.log(16) runs and pops

It prints the result, then comes off.

printSquare finishes and pops

The last frame comes off, leaving the stack empty.

The stack is now empty. The program is done.

If a function calls itself without an exit condition (infinite recursion), the stack overflows:

javascriptjavascript
function loop() {
  loop();
}
loop(); // RangeError: Maximum call stack size exceeded

Web APIs

The call stack alone cannot handle async operations. If fetch() blocked the stack while waiting for a network response, the entire page would freeze.

Instead, async operations are handled by the browser's Web APIs, which run in separate threads outside the JavaScript engine:

  • Timers use the browser's timer thread
  • fetch uses the browser's networking thread
  • Event listeners use the browser's event system
  • The DOM uses the browser's rendering engine

When JavaScript calls setTimeout(callback, 1000), the timer is handed to the browser. The JavaScript engine continues running other code. After 1 second, the browser places the callback into a queue.

The Task Queue and Microtask Queue

There are two queues where callbacks wait:

QueueContainsPriority
Task Queue (Callback Queue)Timer callbacks, event handlersNormal
Microtask QueuePromise callbacks and similar scheduled microtasksHigh

The microtask queue has higher priority. After every task completes, the event loop empties the entire microtask queue before picking up the next task.

The Event Loop

The event loop has one job: check if the call stack is empty, and if it is, move the next callback from a queue to the stack.

Here is the event loop in pseudocode:

texttext
while (true) {
  if (callStack is empty) {
    run all microtasks until the microtask queue is empty;
    if (taskQueue has items) {
      dequeue one task and push it to the callStack;
    }
  }
}

The event loop never stops. It runs as long as the browser tab is open.

A Real Example: Understanding Execution Order

This example demonstrates the full runtime model:

javascriptjavascript
console.log("1: Start");
 
setTimeout(() => {
  console.log("2: setTimeout callback");
}, 0);
 
Promise.resolve().then(() => {
  console.log("3: Promise callback");
});
 
console.log("4: End");

Before reading on, try to guess what prints and in what order. The answer reveals how the queues actually behave:

texttext
1: Start
4: End
3: Promise callback
2: setTimeout callback

Here is why:

First log runs immediately

console.log("1") prints right away.

setTimeout schedules its callback

Even with a 0ms delay, the callback goes to the task queue, not the call stack.

The promise schedules its callback

.then() places the callback in the microtask queue.

Fourth log runs immediately

console.log("4") prints, and the call stack is now empty.

Microtasks run first

The event loop checks the microtask queue before the task queue and runs the Promise callback.

Then the task queue runs

With the microtask queue empty, the event loop finally runs the setTimeout callback.

The setTimeout callback ran last even with a 0ms delay because task queue callbacks always wait for the microtask queue to empty first.

Why This Matters in Practice

Problem 1: Blocking the Main Thread

javascriptjavascript
function heavyComputation() {
  let total = 0;
  for (let i = 0; i < 5_000_000_000; i++) {
    total += i;
  }
  return total;
}

This function alone is harmless to define. The problem starts when a click handler calls it directly on the main thread, with nothing to interrupt the loop:

javascriptjavascript
document.querySelector("button").addEventListener("click", () => {
  console.log(heavyComputation());
});

While the loop runs, the call stack is occupied. The browser cannot handle clicks, scrolls, or rendering. The page freezes.

Solution: Break the work into smaller chunks with setTimeout() so the browser gets a chance to breathe, or move the work off the main thread entirely with a Web Worker:

javascriptjavascript
document.querySelector("button").addEventListener("click", () => {
  // Offload heavy work to a Web Worker
  const worker = new Worker("worker.js");
  worker.postMessage("start");
  worker.onmessage = (e) => {
    console.log("Result:", e.data);
  };
});

Problem 2: Microtask Starvation

Microtasks always run before the next task, which usually feels fast and predictable. But a microtask that keeps scheduling more microtasks can starve the task queue entirely:

javascriptjavascript
function addMicrotask() {
  Promise.resolve().then(() => {
    console.log("Microtask");
    addMicrotask();
  });
}
 
addMicrotask();

Each microtask schedules another microtask before it finishes, so the microtask queue never empties. A timer scheduled after this never gets a chance to run:

javascriptjavascript
setTimeout(() => {
  console.log("This never prints");
}, 0);

The event loop never reaches the task queue. The page becomes unresponsive.

Problem 3: Unexpected Async Timing

Code that looks like it runs in order can still surprise you once an async call is involved, since the line right after a fetch runs before the response arrives:

javascriptjavascript
let data = null;
 
fetch("/api/data")
  .then(response => response.json())
  .then(result => {
    data = result;
  });
 
console.log(data); // null -- the fetch has not completed yet

fetch() is async. The code below it runs before the response arrives. Always handle async data inside the .then() callback or after await.

javascriptjavascript
async function loadData() {
  const response = await fetch("/api/data");
  const data = await response.json();
  console.log(data); // The actual data -- this runs after the response arrives
}
 
loadData();

Putting It All Together

Every time you write async JavaScript, here is what happens:

Your code runs on the call stack

Synchronous code executes line by line.

Async APIs hand off work

Calling fetch, setTimeout, or an event listener passes the work to the browser.

Completed work joins a queue

The callback goes into either the task queue or the microtask queue.

The event loop picks it up

This happens only once the call stack is empty.

Microtasks run first

They always run before the next task.

This model is the foundation of every JavaScript framework, every async operation, and every interactive web experience. For the earlier step in the pipeline, how the browser loads and parses your code, see how browsers read and execute JavaScript code. To see real examples of what this makes possible, read about what JavaScript is used for in web development.

Rune AI

Rune AI

Key Insights

  • JavaScript runs on a single main thread with a call stack that tracks function execution.
  • Async operations like fetch() and setTimeout() are handled by browser Web APIs, not JavaScript itself.
  • The event loop moves callbacks from task queues to the call stack when it is empty.
  • Microtasks (Promise callbacks) run before the next task from the task queue.
  • Long-running synchronous code blocks the page. Use async patterns or Web Workers for heavy work.
RunePowered by Rune AI

Frequently Asked Questions

Is JavaScript really single-threaded?

Yes, the main JavaScript execution thread is single-threaded. It does one thing at a time. However, Web APIs like fetch(), setTimeout(), and DOM events run in separate browser threads and communicate back to JavaScript through the event loop and task queues.

What happens if JavaScript code takes too long to run?

The page freezes. The browser cannot respond to clicks, scrolls, or typing until the long-running code finishes. This is why you should avoid blocking the main thread with heavy computation and use Web Workers for CPU-intensive tasks.

What is the difference between the microtask queue and the task queue?

The microtask queue (Promise callbacks, queueMicrotask) runs after every task completes but before the next task from the task queue. Microtasks have higher priority. If microtasks keep adding more microtasks, the task queue starves and the page never updates.

Conclusion

JavaScript in the browser is a single-threaded language that handles async operations through a clever system of Web APIs, task queues, and the event loop. The call stack runs synchronous code. Async callbacks wait in queues and are picked up by the event loop only when the call stack is empty. Understanding this model is essential for writing responsive applications, debugging timing issues, and reasoning about async code.