What Really Happens When You Call setTimeout()?
Table of Contents

A Deep Dive from JavaScript to Hardware
Every JavaScript developer has used setTimeout. It's one of the first async concepts we learn. But have you ever wondered what actually happens under the hood? Where does your callback function go? Who tracks the time? How does your code "know" when 2 seconds have passed?
The answer involves a fascinating journey through multiple layers of your computer — from high-level JavaScript down to a tiny quartz crystal vibrating on your motherboard.
Let me take you through this journey.
Understanding Node.js Architecture: The Foundation
Before we trace setTimeoutWe need to understand what Node.js actually is. It's not just "JavaScript on the server." It's a carefully orchestrated collaboration between multiple components:
Node.js = V8 + libuv + Bindings + APIsV8 is Google’s JavaScript engine — the same one powering Chrome. V8’s job is straightforward: parse your JavaScript code, compile it, and execute it. It manages two critical memory areas: the Call Stack (where your functions execute, one at a time, in LIFO order) and the Heap (where your variables, objects, and function definitions live).
But here’s something crucial that many developers don’t realize: V8 only understands pure JavaScript. Things like setTimeout, fs.readFile, http.createServer — V8 has no idea what these are. They're not part of the JavaScript language specification.
libuv is the unsung hero. Written in C, libuv handles everything async: timers, file system operations, network requests, and most importantly, the Event Loop. When V8 encounters something it doesn’t understand (like setTimeout), it passes the work to libuv through C++ bindings.
This division of labor is the key to understanding async JavaScript.
The Moment You Call setTimeout: What V8 Does
Let’s trace what happens when you write this code:
console.log("Start");
setTimeout(() => {
console.log("Hello from timeout!");
}, 2000);
console.log("End");V8 starts executing your code line by line. It sees console.log("Start") — that's pure JavaScript, V8 handles it directly. You see "Start" in your console.
Then V8 encounters setTimeout. It looks at this and essentially says: "I don't know what this is. This isn't my responsibility." V8 immediately hands off this entire operation to libuv through the C++ bindings.
And here’s the critical part: V8 doesn’t wait. It doesn’t pause. It doesn’t care what happens to that timer. The moment it hands off setTimeout to libuv, V8 moves to the next line and executes console.log("End").
This is why you see:
Start
End
Hello from timeout! // 2 seconds laterThe synchronous code runs first, completely. The async callback comes later.
Where Does Your Callback Function Actually Live?
This question puzzled me for a long time. When you write:
javascript
setTimeout(() => {
console.log("Hello!");
}, 2000);That arrow function () => { console.log("Hello!"); } — Where is it stored for 2 seconds? Does libuv copy it? Does it live in some special async memory? The answer is elegant:

Your callback function is created and stored in V8’s Heap Memory the moment V8 parses that line. It gets assigned a memory address.
libuv doesn’t copy the function. It simply stores a pointer — a reference to where that function lives in V8’s memory. Along with this pointer, libuv maintains a data structure that looks something like this:
// Simplified representation of libuv's timer structure
struct uv_timer_s {
void* callback_ptr; // Points to your function in V8 Heap (0x7fff001)
uint64_t timeout; // 2000 (milliseconds)
uint64_t start_time; // When the timer was registered
int status; // PENDING → READY → EXECUTED
// ... other internal fields
}When the time comes to execute your callback, libuv follows that pointer back to V8’s Heap, retrieves the function, and tells V8 to execute it.
Who Actually Tracks the Time? (Hint: It’s Not Software)
Here’s where things get really interesting. When libuv registers a 2000ms timer, it doesn’t sit there counting milliseconds. Neither does your operating system, really. The actual timekeeping happens at the hardware level.
On your computer’s motherboard, there’s a small quartz crystal — the same technology that makes your wristwatch tick. When you apply an electric current to quartz, it vibrates at an incredibly precise and consistent frequency. This is called the crystal oscillator.
Your CPU’s clock speed is derived from this crystal. A 3GHz processor means the crystal (through various multipliers) produces 3 billion “ticks” per second.
So when you request a 2000ms timer, here’s the math:
2000ms × 3,000,000 ticks per millisecond = 6,000,000,000 ticksThe hardware maintains a counter. It increments this counter with every tick of the crystal. When the counter reaches the target value, it triggers an event.
The beautiful part? Your CPU doesn’t have to do anything during this counting. The hardware counts autonomously. Your CPU can be doing other work, or even sleeping. The silicon circuits on your motherboard are counting those 6 billion ticks without any software involvement.
The Interrupt: How Hardware Talks to Software
When the hardware counter reaches its target, how does the software find out? This is where interrupts come in — one of the most elegant mechanisms in computer architecture.
An interrupt is exactly what it sounds like: an electrical signal that interrupts whatever the CPU is currently doing. The hardware timer sends this signal directly to the CPU.
Here’s what happens in sequence:
- Hardware timer expires — the counter hits the target tick count
- Interrupt signal fires — an electrical signal travels to the CPU
- CPU pauses immediately — whatever instruction it was executing gets suspended
- CPU jumps to the Interrupt Handler — a special piece of OS code
- Interrupt Handler identifies the source — “this interrupt came from the timer for process 12345 (Node.js).”
- OS notifies libuv — “hey, your 2000ms timer is done.”
- CPU resumes previous work — goes back to whatever it was doing before
This entire sequence happens in microseconds.
Why does this matter? Without interrupts, the CPU would have to constantly check: “Is the timer done? Is it done now? How about now?” This is called polling, and it’s incredibly wasteful — you’d burn 100% CPU doing nothing useful.
With interrupts, the CPU utilization while waiting for a timer is essentially 0%. The CPU can sleep or do other work, confident that the hardware will wake it up when needed.
The Event Loop: Not Magic, Just a While Loop
The Event Loop sounds mystical, but it’s literally just a while loop inside libuv. Here's a simplified representation:
// Inside libuv (conceptually)
while (there_is_work_to_do()) {
// Phase 1: TIMERS
// Execute callbacks for expired setTimeout and setInterval
process_timers();
// Phase 2: PENDING CALLBACKS
// Execute I/O callbacks deferred from previous cycle
// (like TCP connection errors)
process_pending_callbacks();
// Phase 3: IDLE, PREPARE
// Internal housekeeping (you rarely interact with this)
process_idle_handlers();
// Phase 4: POLL ⭐ (The Most Important Phase)
// Retrieve new I/O events
// Execute I/O callbacks (file read complete, data received, etc.)
// If nothing to do: SLEEP here until something happens
poll_for_events();
// Phase 5: CHECK
// Execute setImmediate callbacks
process_check_handlers();
// Phase 6: CLOSE CALLBACKS
// Execute close event callbacks (socket.on('close'))
process_close_callbacks();
}The Poll phase deserves special attention. This is where the Event Loop spends most of its time. When there’s nothing to do — no timers expiring, no I/O completing — the Poll phase calls operating system primitives like epoll_wait (Linux), kqueue (macOS), or IOCP (Windows).
These OS calls are blocking but efficient. They tell the OS: “Put me to sleep until one of these file descriptors has activity, or until a timer expires.” The CPU goes to sleep. Power consumption drops. But the moment any event occurs, the OS wakes up your process.
This is why a Node.js server handling zero requests uses ~0.1% CPU, not 100%. The Event Loop isn’t spinning frantically checking for work — it’s sleeping, waiting to be awakened.
When does the Event Loop exit? When there’s truly nothing left: no pending timers, no open handles (servers, sockets), no pending I/O operations, no setImmediate callbacks queued. The Event Loop checks "do I have any reason to keep running?" and if the answer is no, your Node.js process exits.
The Golden Rule That Explains Everything
Here’s the single most important rule for understanding async JavaScript:
The Event Loop will only move a callback from the Queue to the Call Stack when the Call Stack is COMPLETELY EMPTY.
If your Call Stack has any function executing — even one — all queued callbacks must wait. No exceptions.
Let’s see this in action:
const start = Date.now();
setTimeout(() => {
console.log("Timer callback!", Date.now() - start);
}, 100);
// This loop takes about 2 seconds
for (let i = 0; i < 2_000_000_000; i++) {
// Blocking the Call Stack
}
console.log("Loop finished!", Date.now() - start);What do you expect to see?
You might think: “The timer is set for 100ms, so I’ll see the timer callback somewhere around 100ms, and the loop will finish around 2000ms.”
Wrong. Here’s what actually happens:
Loop finished! 2000
Timer callback! 2001Let me trace this step by step:
- 0ms: setTimeout is called. libuv registers a timer for 100ms.
- 0ms: The for loop starts. The Call Stack is now occupied.
- 100ms: Hardware interrupt fires! OS notifies libuv. libuv marks the timer as READY and pushes the callback to the queue.
- 100ms — 2000ms: The callback is sitting in the queue, waiting. The Event Loop checks “Is the Call Stack empty?” No, the for-loop is still running. Callback waits.
- 2000ms: The for-loop finishes. console.log("Loop finished!") executes.
- 2000ms: The Call Stack is finally empty!
- 2000ms: Event Loop moves the callback from Queue → Stack.
- 2001ms: Callback executes, prints “Timer callback! 2001”
The timer fired at 100ms. The callback was ready at 100ms. But it couldn’t execute until 2000ms because the Call Stack was blocked.
This is why setTimeout(fn, X) guarantees a MINIMUM delay of X milliseconds, never an exact delay. The actual delay depends on what else is happening on the Call Stack.
The Unavoidable Overhead: Why Even 0ms Isn’t Instant
Even if you have no blocking code, you’ll notice that timers have a small overhead:
const start = Date.now();
setTimeout(() => {
console.log("Delay:", Date.now() - start, "ms");
}, 0);Run this, and you’ll see Delay: 1 ms or Delay: 2 ms or Delay: 4 ms — never Delay: 0 ms.
Where does this 1–4ms overhead come from?
- OS Scheduling Delay (~0.5ms): Your Node.js process isn’t always running. The OS might be giving CPU time to other processes. When the timer expires, the OS needs to schedule your process to run.
- Context Switch (~0.3ms): Moving from kernel mode (where the interrupt was handled) to user mode (where your Node.js runs) takes time.
- libuv Processing (~0.2ms): libuv needs to find the right timer in its data structures, mark it as ready, and push the callback to the appropriate queue.
- Event Loop Cycle (~0.2ms): The Event Loop might be in a different phase when the timer expires. It needs to complete its current phase and cycle around to the timers phase.
- V8 Execution Setup (~0.2ms): V8 needs to set up the execution context for your callback, load it onto the Call Stack, and begin execution.
Each of these adds a small delay. Combined, they account for the 1–4ms you typically observe.
The Complete Journey: Tracing setTimeout from Start to Finish
Let’s put it all together. When you writesetTimeout(fn, 2000), here's the complete journey:
┌─────────────────────────────────────────────────────────────┐
│ YOUR CODE │
│ setTimeout(() => console.log("Hi"), 2000); │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ V8 ENGINE │
│ "I don't handle setTimeout. Passing to libuv." │
│ Stores callback function in Heap Memory at 0x7fff001 │
│ Immediately moves to next line of code │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ LIBUV │
│ Creates timer struct: {callback: 0x7fff001, timeout: 2000} │
│ Registers with OS: "Notify me in 2000ms" │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ OPERATING SYSTEM │
│ Adds to kernel timer list │
│ Tells hardware: "Interrupt me after 6 billion ticks" │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ HARDWARE (Crystal Oscillator) │
│ Counts ticks autonomously │
│ CPU is free to do other work or sleep │
│ ... 2000ms passes ... │
│ Target reached → Fires INTERRUPT signal │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ OS INTERRUPT HANDLER │
│ CPU pauses current work │
│ Identifies: "This is for Node.js process" │
│ Signals libuv │
│ CPU resumes previous work │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ LIBUV │
│ Marks timer: PENDING → READY │
│ Pushes callback to Timer Queue │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ EVENT LOOP (Timers Phase) │
│ Checks: "Is Call Stack empty?" │
│ If yes → Pops callback from Queue → Pushes to Call Stack │
│ If no → Callback waits until Stack clears │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ V8 ENGINE │
│ Retrieves function from Heap (0x7fff001) │
│ Executes: console.log("Hi") │
│ Pops callback from Call Stack │
└─────────────────────────────────────────────────────────────┘
↓
✅ DONEKey Takeaways
After this deep dive, here’s what you should remember:
- V8 only handles pure JavaScript. Everything async — timers, I/O, networking — is delegated to libuv.
- Your callback lives in V8’s Heap. libuv just stores a pointer to it, not a copy.
- Hardware tracks time, not software. A quartz crystal oscillator counts ticks autonomously while your CPU does other work.
- Interrupts are electrical signals that wake the CPU when the timer expires. This is far more efficient than polling.
- The Event Loop is just a while loop that sleeps when idle (via OS primitives like epoll/kqueue) and wakes when events occur.
- Callbacks must wait for the Call Stack to be empty. If your synchronous code blocks, all async callbacks are delayed.
- setTimeout guarantees minimum delay, not exact timing. The actual delay depends on Call Stack availability and system overhead.
Understanding this complete picture transforms how you think about async JavaScript. You stop seeing setTimeout as magic and start seeing it as a beautiful collaboration between your code, V8, libuv, your operating system, and the physical hardware on your motherboard.
The next time you write setTimeout, you'll know exactly what's happening — all the way down to the crystal.
Working on something similar? Get in touch
Have questions about this article, architecture patterns, or looking for pair programming and engineering consulting? My inbox is always open.
Send me an emailRelated Articles
how 'this' behaves in the call, bind, apply
**a brief intro of this with call, bind and apply**
Confusion will be gone about 'this'
Most confusion about `this` comes from one wrong assumption: that it's decided by where a function is **written**. It isn't. `this` is decided by how the function is **called**.
Understanding React’s Virtual DOM: The Secret Behind Lightning-Fast UIs
How React updates your apps in milliseconds, not seconds The Problem: Why DOM Updates Are Slow Imagine you’re building a social media feed. Every like, comment,...