How do Timers and scheduling work in Node.js?
Node.js timers - functions that schedule code to run at specific phases of the event loop, not at a precise wall-clock time.
Theory
TL;DR
- Four scheduling functions:
setTimeout,setInterval,setImmediate,process.nextTick process.nextTickand resolved Promises are microtasks - they always run before any I/O callbackssetImmediatefires in the check phase, after I/O poll;setTimeout(fn, 0)fires in the timers phase- Timers guarantee a minimum delay, not an exact one
- Outside an I/O callback, the order of
setTimeout(fn, 0)vssetImmediateis non-deterministic
Quick example
console.log('start');
process.nextTick(() => console.log('nextTick')); // microtask, first
Promise.resolve().then(() => console.log('Promise')); // microtask, second
setTimeout(() => console.log('setTimeout'), 0); // timers phase
setImmediate(() => console.log('setImmediate')); // check phase
console.log('end');
// Output:
// start
// end
// nextTick
// Promise
// setTimeout (may swap with setImmediate outside I/O)
// setImmediateThe microtask queue drains completely before the event loop moves to the next phase. That is the one rule that never changes.
How the event loop schedules each timer
Node.js runs its event loop in ordered phases. Each timer function hooks into a different one.
process.nextTick is not part of the event loop at all. Node drains the nextTick queue after every single operation before handing control back to libuv. This means recursive nextTick calls will starve I/O entirely.
Promise.then callbacks go into the microtask queue and run right after nextTick. From Node.js 11+, microtasks flush between each individual event loop callback, not just between phases.
setTimeout(fn, delay) registers in the timers phase. With delay = 0, the actual minimum is roughly 1ms. Exact firing depends on how long the current phase takes and what the OS timer resolution is.
setImmediate fires in the check phase, right after the poll phase completes. Inside an I/O callback, setImmediate always fires before setTimeout(fn, 0). Outside one, do not assume any order.
setInterval behaves like repeated setTimeout calls but does not account for callback execution time. Under load, intervals drift because the next timer is measured from when the previous callback was scheduled, not when it finished.
Timer accuracy
Timers mark a minimum threshold, not a clock tick:
const start = Date.now();
setTimeout(() => {
console.log(`Delay: ${Date.now() - start}ms`);
// Requested: 100ms. Actual: 101-115ms, more under load
}, 100);If the event loop is blocked by a synchronous operation, your timer fires late. There is no mechanism to interrupt a running synchronous block. Seen this surprise teams around billing jobs - they schedule a periodic sync with setTimeout and only discover the drift after their first traffic spike.
Promisified timers (Node.js 15+)
timers/promises gives you awaitable versions of all three:
const { setTimeout: sleep, setImmediate: immediate } = require('timers/promises');
async function example() {
await sleep(1000); // waits 1 second
await immediate(); // yields to the check phase
console.log('done');
}They also accept an AbortSignal, which makes cancellation straightforward:
const { setTimeout: sleep } = require('timers/promises');
const ac = new AbortController();
setTimeout(() => ac.abort(), 500);
await sleep(2000, undefined, { signal: ac.signal }); // AbortError at 500msNo timer IDs to track manually. No clearTimeout scattered across try/catch blocks.
Common mistakes
1. Expecting exact timing for production jobs
// Unreliable
setTimeout(() => sendDailyReport(), 24 * 60 * 60 * 1000);The process can restart, the event loop can be blocked, the system clock can drift. For anything important, use node-cron or a message queue.
2. Starving I/O with recursive nextTick
function loop() {
process.nextTick(loop); // I/O never gets through
}
loop();The nextTick queue drains fully before I/O. This blocks all network and file operations indefinitely. Use setImmediate when you just need to defer something.
3. setInterval drift under load
// Callback takes 200ms, interval is 1000ms
// Real gap: 800ms between end of callback and next fire
setInterval(async () => {
await processQueue(); // takes variable time
}, 1000);When execution time matters, use recursive setTimeout instead. Start the next timer after the current callback finishes.
4. Assuming setTimeout(0) always fires before setImmediate
setTimeout(() => console.log('A'), 0);
setImmediate(() => console.log('B'));
// Output: A B or B A - depends on OS timer resolution at this exact momentInside an I/O callback, it is always B then A. Outside, the spec does not guarantee order.
Real-world patterns
- Debounce:
clearTimeout+setTimeouton every invocation, for search inputs and resize handlers - Exponential backoff: recursive
setTimeoutwithMath.pow(2, attempt) * 1000delay between API retries - CPU work chunking:
setImmediatebetween iterations to yield to I/O without blocking the loop - Request timeout:
AbortController+setTimeout+clearTimeouton response received - Graceful shutdown:
setTimeout(process.exit, 5000)as a hard stop fallback after SIGTERM
Follow-up questions
Q: What is the difference between process.nextTick and setImmediate?
A: process.nextTick runs before the event loop moves to any next phase, draining its entire queue first. setImmediate runs in the check phase, after I/O poll. The names are misleading: setImmediate is the one that actually defers to the next loop iteration.
Q: Why is the order of setTimeout(fn, 0) vs setImmediate non-deterministic outside I/O?
A: Because setTimeout with delay 0 sets a minimum of 1ms. When the script starts, if that 1ms has already passed by the time the timers phase checks, setTimeout fires first. If not, the loop moves to check phase and setImmediate fires first. OS timer resolution determines which case you hit.
Q: Can process.nextTick crash or block the server?
A: It will not overflow the call stack, but it will starve I/O. Queuing nextTick callbacks from inside nextTick callbacks means network events and file reads never execute. setImmediate is the safer choice when you just want to defer work.
Q: How does Node sleep between timers when nothing else is happening?
A: libuv calculates the nearest timer expiry and blocks the poll phase for exactly that duration at the OS level. So setTimeout(fn, 1000) with no other pending work does not spin the CPU. Node sleeps until the timer fires.
Q: When should you prefer timers/promises over the callback API?
A: Whenever you are inside an async function and need clean cancellation. The callback API has no built-in AbortSignal support and forces you to track timer IDs manually across try/catch blocks.
Examples
Execution order across all four scheduling functions
// Run this in Node.js to verify your mental model
setImmediate(() => console.log('setImmediate'));
setTimeout(() => console.log('setTimeout(0)'), 0);
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('Promise.then'));
// Guaranteed:
// 1. nextTick
// 2. Promise.then
// 3. setTimeout(0) or setImmediate (non-deterministic outside I/O)
// 4. setImmediate or setTimeout(0)nextTick and Promise.then always come first. Between setTimeout(0) and setImmediate, do not write code that depends on either order.
Retry with exponential backoff
async function fetchWithRetry(url, maxAttempts = 4) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
if (attempt === maxAttempts - 1) throw err;
const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}Each failed attempt waits 1s, then 2s, then 4s. The final throw re-raises the original error to the caller without wrapping it.
CPU-intensive work split with setImmediate
function processLargeArray(items, callback) {
let index = 0;
function processChunk() {
const end = Math.min(index + 100, items.length);
while (index < end) {
callback(items[index++]);
}
if (index < items.length) {
setImmediate(processChunk); // yield to I/O between chunks
}
}
processChunk();
}Without setImmediate, processing 100,000 items blocks the event loop for the entire duration. With it, incoming HTTP requests get handled between chunks. The tradeoff is that total processing time increases slightly.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.