Skip to main content

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.nextTick and resolved Promises are microtasks - they always run before any I/O callbacks
  • setImmediate fires 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) vs setImmediate is non-deterministic

Quick example

js
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) // setImmediate

The 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:

js
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:

js
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:

js
const { setTimeout: sleep } = require('timers/promises'); const ac = new AbortController(); setTimeout(() => ac.abort(), 500); await sleep(2000, undefined, { signal: ac.signal }); // AbortError at 500ms

No timer IDs to track manually. No clearTimeout scattered across try/catch blocks.

Common mistakes

1. Expecting exact timing for production jobs

js
// 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

js
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

js
// 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

js
setTimeout(() => console.log('A'), 0); setImmediate(() => console.log('B')); // Output: A B or B A - depends on OS timer resolution at this exact moment

Inside an I/O callback, it is always B then A. Outside, the spec does not guarantee order.

Real-world patterns

  • Debounce: clearTimeout + setTimeout on every invocation, for search inputs and resize handlers
  • Exponential backoff: recursive setTimeout with Math.pow(2, attempt) * 1000 delay between API retries
  • CPU work chunking: setImmediate between iterations to yield to I/O without blocking the loop
  • Request timeout: AbortController + setTimeout + clearTimeout on 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

js
// 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

js
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

js
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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?