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.
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
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.
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:
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:
function loop() {
loop();
}
loop(); // RangeError: Maximum call stack size exceededWeb 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:
| Queue | Contains | Priority |
|---|---|---|
| Task Queue (Callback Queue) | Timer callbacks, event handlers | Normal |
| Microtask Queue | Promise callbacks and similar scheduled microtasks | High |
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:
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:
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:
1: Start
4: End
3: Promise callback
2: setTimeout callbackHere 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
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:
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:
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:
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:
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:
let data = null;
fetch("/api/data")
.then(response => response.json())
.then(result => {
data = result;
});
console.log(data); // null -- the fetch has not completed yetfetch() is async. The code below it runs before the response arrives. Always handle async data inside the .then() callback or after await.
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
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.
Frequently Asked Questions
Is JavaScript really single-threaded?
What happens if JavaScript code takes too long to run?
What is the difference between the microtask queue and the task queue?
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.
More in this topic
JavaScript While Loop Explained: A Complete Guide
A while loop repeats code as long as a condition stays true. Learn the syntax, see practical examples, and understand when a while loop is the right choice over a for loop.
JavaScript Loops Tutorial: for, while, do while
Loops let JavaScript repeat code without writing it twice. Learn the three core loop types, what each one does, and when to choose one over another.
How to Loop Through Arrays Using JS for Loops Guide
Looping through arrays is the most practical use of JavaScript for loops. Learn how to access each element by index, avoid off-by-one errors, and choose between for, for...of, and forEach.