What Really Happens When You Run JavaScript Code
Introduction
You think you know JavaScript… but can you explain why setTimeout doesn’t always run when you expect?
The answer is in how JavaScript schedules and executes tasks. But how does it actually work?
To understand this, we need to talk about the event loop, what it is and how it works behind the scenes.
What is the Call Stack?
The event loop is a runtime mechanism that allows JavaScript to decide which task to execute next.
Before understanding it, you need to know that JavaScript is:
- Single-threaded (it runs on a single main thread)
- Non-blocking (it can handle asynchronous operations without stopping execution)
So how does the event loop manage this single thread and decide which task to execute next without blocking the execution?
We first need to look at the call stack, which, as the name implies, is a stack that handles all function calls in JavaScript. Let’s take a look at this code to understand how it works.
function first(){
console.log("I'm first function");
second()
}
function second(){
console.log("I'm second function");
third()
}
function third(){
console.log("I'm third function");
fourth()
}
function fourth(){
console.log("I'm forth function");
}
fourth()
The call stack tries to execute code in a strict order, line by line.
It starts by encountering the call to first(). At this point, first() is pushed onto the stack and executed.
Inside first(), there is a call to second(), so second() is pushed on top of the stack and executed next.
Then second() calls third(), which is also pushed onto the stack.
Finally, third() calls fourth(), which is pushed last and executed at the top of the stack.
At the moment when fourth() is being executed, the stack looks like:
fourth()
third()
second()
first()
Then execution happens from the top of the stack. fourth() executes first and is removed from the stack, then control returns to third(), followed by second(), and finally first().
But it still doesn’t explain something important: why doesn’t setTimeout always run when we expect it?
Understanding Async Execution
To understand that, we need to step outside the call stack and look at how JavaScript handles asynchronous tasks.
There is a task queue, which is where asynchronous tasks are stored once they are ready to be executed. It is essentially a waiting line of callbacks that will be processed later.
However, these tasks are not executed immediately. Even if a task is ready, it must wait until the call stack is empty before it can be processed. This is because JavaScript always executes synchronous code first.
This is what makes JavaScript non-blocking in practice.
How setTimeout Really Works
To better understand how the task queue and the call stack work together, let’s look at the following example and try to predict its output:
function prepareDinner() {
setTimeout(() => {
console.log("I'm here");
}, 0);
console.log("Starting dinner preparation");
cookFood();
}
function cookFood() {
console.log("Cooking food");
serveFood();
}
function serveFood() {
console.log("Serving food");
}
prepareDinner();
At first glance, you might think the output will be:
I'm here
Starting dinner preparation
Cooking food
Serving food
But the actual output is:
Starting dinner preparation
Cooking food
Serving food
I'm here
So why does this happen, even though the delay is set to 0?
As we said before, JavaScript executes synchronous code first, which is pushed directly to the call stack.
Asynchronous code, like setTimeout, is handled separately and its callback is pushed to the task queue.
JavaScript will only execute tasks from the queue after finishing all the code in the call stack.
So even if setTimeout is set to 0, it doesn’t run immediately. It just means that the callback will be pushed to the task queue after 0ms, that’s all.
For example, if you set a setTimeout with a delay of 1s, the callback will be pushed to the task queue after 1 second.
But it may take more than 1 second to actually execute, because it still has to wait for the call stack to become empty.
There is one more important thing in the event loop that we didn’t introduce yet.
You might think that all asynchronous tasks are the same, and that all of them simply wait for the call stack to be empty before being executed, right?
There is another queue with higher priority than the task queue (which we can refer to as the macrotask queue, where setTimeout callbacks are placed).
This higher-priority queue is called the microtask queue.
This is where Promise.then callbacks are executed.
Microtasks have higher priority than the macrotask queue (like setTimeout), but they do not run before the call stack.
Instead, once the call stack becomes empty, JavaScript will execute all microtasks first, before moving to macrotasks.
Let’s take a look at this code to understand better:
function prepareDinner() {
setTimeout(() => {
console.log("I'm here");
}, 0);
Promise.resolve().then(() => {
console.log("Promise resolved");
});
console.log("Starting dinner preparation");
cookFood();
}
function cookFood() {
console.log("Cooking food");
serveFood();
}
function serveFood() {
console.log("Serving food");
}
prepareDinner();
So at the moment prepareDinner() starts executing:
- The call stack contains:
serveFood
cookFood
prepareDinner
- The macrotask queue contains:
setTimeout callback
- The microtask queue contains:
Promise callback
We said that the sync code in the call stack executes first so the output of it will be
Starting dinner preparation
Cooking food
Serving food
then the microtask right after when the call stack gets empty
Promise resolved
the macrotask gets executed only when the call stack is empty and all microtasks have already been executed
I'm here
The final output will be:
Starting dinner preparation
Cooking food
Serving food
Promise resolved
I'm here
Web APIs and JavaScript Environment
We said that setTimeout is async code that gets pushed to the macrotask queue, and that is true, but there is something missing here.
setTimeout(), like fetch, and addEventListener, are external APIs provided by the browser (or by Node.js in backend environments). They are not part of JavaScript itself.
JavaScript only provides the execution engine (call stack + event loop model), but these APIs are provided by the environment.
For example, setTimeout does not get pushed directly to the macrotask queue.
When setTimeout is called, it first goes to the Web APIs, where the timer starts counting.
Only after the timer finishes does the callback get pushed to the macrotask queue, waiting for its turn to be executed.
Then, the event loop will pick it only when the call stack is empty and after all microtasks are finished.
Conclusion
Now you can explain with much more confidence why setTimeout does not guarantee execution immediately after the time has finished.
The reason is that setTimeout is not executed directly by JavaScript. Even when the timer is done, the callback still has to wait in the task queue until the call stack is empty and all microtasks are completed.
Only then does the event loop pick it up and push it to the call stack for execution.
That’s why the delay in setTimeout is not an exact execution time, but only a minimum waiting time before it becomes eligible to run.
Hope you enjoyed this article. Feel free to explore more posts to keep learning.
Enjoyed this article?
I'm currently open to new roles, I'd love to hear from you.