At first glance, JavaScript timers look simple: setTimeout and setInterval schedule work for later.
But once you move from the browser to Node.js, a subtle difference appears—setTimeout no longer returns a number, it returns an object.
This difference is not cosmetic, nor is it a TypeScript quirk. It reflects a deep design choice in Node.js around process lifecycle management, server reliability, and graceful shutdowns.
This post explains, in depth, why Node.js timers return objects, how ref() and unref() work, and why setInterval is significantly more dangerous than setTimeout in backend systems.
Browser Timers vs Node.js Timers
In browsers, timers are part of the Web APIs. Calling setTimeout returns a numeric identifier:
const id = setTimeout(fn, 1000);
That number is merely a lookup key. The browser maintains an internal timer table and uses the number to cancel the timer if needed.
The browser itself is already running a UI event loop, so timers have no influence over whether the environment stays alive.
Node.js operates under very different constraints.
Node.js Is a Process That Must Decide When to Exit
Node.js typically runs as a server or CLI process. Unlike a browser, it must continuously answer a critical question:
“Is there any work left that requires me to stay alive?”
To answer that, Node tracks active resources:
- open servers
- open sockets
- file descriptors
- timers
If no active resources remain, Node exits.
This is the key reason Node timers are not just numbers.
Why setTimeout Returns an Object in Node.js
In Node.js, setTimeout returns a timer handle object (commonly typed as NodeJS.Timeout). This object represents a real, registered resource in the event loop.
Because it is an object, it can:
- participate in lifecycle tracking
- influence whether the process stays alive
- expose methods that change that behavior
A numeric ID cannot do any of that.
The Meaning of ref() and unref()
Default behavior: timers are “ref’d”
By default, every timer created with setTimeout or setInterval is ref’d.
Conceptually, this means:
“This timer is important. Do not let the process exit until it is done.”
This is why the following code keeps Node alive for 10 seconds:
setTimeout(() => {
console.log("done");
}, 10_000);
Calling ref() explicitly is unnecessary because this behavior is already the default.
unref(): making a timer optional
Calling unref() changes the timer’s role:
const t = setTimeout(task, 10_000);
t.unref();
Now the timer says:
“If I am the only thing left, do not wait for me.”
If all other resources are gone, Node will exit immediately, and the timer may never fire. This is intentional and extremely useful for background or best-effort work.
ref(): undoing unref()
The ref() method exists solely to reverse unref():
t.ref();
You typically only call ref() in:
- libraries
- reusable infrastructure code
- situations where timer importance changes dynamically
In normal application code, calling ref() manually is almost always unnecessary.
Why setInterval Is Riskier Than setTimeout
The core difference is simple but critical:
-
setTimeoutruns once and cleans itself up -
setIntervalruns forever unless explicitly stopped
Lifecycle implications
A setTimeout:
- registers a timer
- fires once
- unregisters itself
- stops keeping the process alive
A setInterval:
- registers a timer
- fires repeatedly
- never unregisters itself
- keeps the process alive indefinitely
If you forget about a setTimeout, the problem ends on its own.
If you forget about a setInterval, the process may never exit.
The Silent Production Bug
This code looks harmless:
setInterval(() => {
collectMetrics();
}, 60_000);
But during shutdown:
- HTTP servers close
- database connections close
- all real work is done
Yet the process hangs.
Why? Because the interval is still ref’d and still alive.
Unless you:
clearInterval(interval);
or:
interval.unref();
Node has no reason to exit.
Overlapping Execution: Another setInterval Hazard
setInterval does not care whether the previous execution finished:
setInterval(async () => {
await slowTask(); // takes 10s
}, 5_000);
This leads to overlapping executions, which can cause:
- race conditions
- unbounded concurrency
- memory growth
- database overload
Why Recursive setTimeout Is Safer
A common and safer pattern is:
async function loop() {
await slowTask();
setTimeout(loop, 5_000);
}
loop();
This guarantees:
- no overlap
- predictable pacing
- natural cleanup
- safer shutdown behavior
This pattern aligns better with Node’s lifecycle model.
Best Practices Summary
- Node timers return objects because they are real event-loop resources
- Timers are
ref()’d by default - Use
unref()for background or best-effort tasks - Avoid
setIntervalunless you fully control its lifecycle - Prefer recursive
setTimeoutfor repeated async work - Always think about shutdown behavior in server code
💡 Have questions? Drop them in the comments!
Top comments (0)