DEV Community

Cover image for Why setTimeout Returns an Object in Node.js (and Why setInterval Can Break Your App)
Ali nazari
Ali nazari

Posted on

Why setTimeout Returns an Object in Node.js (and Why setInterval Can Break Your App)

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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:

  • setTimeout runs once and cleans itself up
  • setInterval runs forever unless explicitly stopped

Lifecycle implications

A setTimeout:

  1. registers a timer
  2. fires once
  3. unregisters itself
  4. stops keeping the process alive

A setInterval:

  1. registers a timer
  2. fires repeatedly
  3. never unregisters itself
  4. 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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

or:

interval.unref();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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 setInterval unless you fully control its lifecycle
  • Prefer recursive setTimeout for repeated async work
  • Always think about shutdown behavior in server code

💡 Have questions? Drop them in the comments!

Top comments (0)