skip to content

What does `setTimeout` return in a browser versus in Node, and what code breaks when you assume the two are the same?

level: middleimportance: should knowfreq 42%

answer

  1. handle types differ by host
  2. number here, object there
  3. opaque handle, hand it back
  4. null the field to mean not pending
  5. pending Node timer holds the process open

basics

~20 s

Browsers return a positive integer id. Node returns a Timeout object with methods such as ref, unref and refresh. Code breaks when it treats the handle as a number — comparing it, serializing it, or storing it as a numeric id in shared state.

solid answer

~40 s

In a browser, `setTimeout` returns an opaque positive integer, and `clearTimeout` takes that integer. In Node it returns a `Timeout` **object** carrying `ref()`, `unref()` and `refresh()`; `clearTimeout` accepts the object. Both are meant to be opaque handles you hand straight back, so portable code stores whatever it got and passes it to `clearTimeout` unchanged. Assumptions that break: `if (id > 0)` to test "a timer is pending" — in Node the object compares as `false` there, while in a browser it is fine; `JSON.stringify` of state holding a handle — a number survives, a `Timeout` serializes into a useless object; and any `typeof handle === 'number'` check. The reliable pending test is `handle !== null`, resetting the field to `null` in the callback and after clearing.

code

javascript · 21 lines
javascript
let handle = null;

function start(ms, fn) {
  stop();
  handle = setTimeout(() => {
    handle = null;
    fn();
  }, ms);
}

function stop() {
  clearTimeout(handle); // no-op when null
  handle = null;
}

function isPending() {
  return handle !== null;
}

start(50, () => console.log('fired', isPending()));
console.log('pending before firing:', isPending());

go deeper

for a junior

Know that the value setTimeout gives you exists only to be passed back to clearTimeout, and that its concrete type is not the same in a browser as in Node.

for a middle

Name the two types — an integer id versus a Timeout object — and explain what breaks: truthiness checks, typeof checks and serialization. Describe the null-the-field idiom for tracking pending state.

for a senior

Bring in operational consequences: timers that outlive teardown fire against dead state, and a pending Node timer stops a process or a test run from exiting, which is what unref() addresses.

for a principal

Frame it as an API-surface decision for shared code: expose cancellation as a returned function or an abort-based contract so callers never handle raw host timer values, and the portability question disappears from every call site.

## The two return values Timers are host APIs, not language features, so each host chose its own handle type. **Browsers** return "a positive integer value which identifies the timer" — a plain number, unique within that global scope. Nothing else is promised: its magnitude, ordering and reuse after clearing are implementation detail. **Node** returns a `Timeout` object. It exposes: - `unref()` — stop this timer from keeping the process alive; - `ref()` — the reverse, restoring the default; - `refresh()` — restart the countdown from now without allocating a new timer. Node's `Timeout` also defines `Symbol.toPrimitive`, so coercing it (`+handle`, or using it as an object key) yields an integer id, and `clearTimeout` accepts either that id or the object. That coercion exists for compatibility, not as an invitation to rely on it. ```js const h = setTimeout(() => {}, 1000); console.log(typeof h); // "number" in a browser, "object" in Node clearTimeout(h); // correct in both ``` ## What actually breaks **Truthiness and comparison tests.** The classic idiom `if (timerId > 0)` or `if (timerId)` to mean "a timeout is pending" behaves differently in each host. A `Timeout` object is always truthy, so `if (timerId)` reports pending forever unless you null the field; and `timerId > 0` coerces the object and is easy to get wrong as a portability assumption. Meanwhile a browser id is a number that is always truthy too — until you try `if (timerId !== 0)` and a host hands out `0`, which the spec does not forbid. **Serialization.** Storing a handle inside state that gets `JSON.stringify`-ed, sent to a worker, or written to a store: a number round-trips harmlessly, a `Timeout` becomes an object graph that is meaningless on the other side, and structured-clone-style transfers reject or mangle it. **Type assumptions in shared code.** Utility modules that declare a numeric timer field, or check `typeof h === 'number'` before clearing, silently stop clearing anything under the other host — producing the worst kind of bug, a timer that fires after teardown. **Process lifetime.** This one has no browser analogue at all. A pending Node timer keeps the event loop alive, so a CLI or test that forgets to clear a long timeout simply does not exit. That is what `unref()` is for on a keepalive-style timer, and there is nothing to port to the browser, where a tab's lifetime is not governed by pending timers. ## The portable discipline Treat the handle as opaque: ```js class Poller { #handle = null; start(ms) { this.stop(); this.#handle = setTimeout(() => { this.#handle = null; // pending state is yours, not the handle's this.tick(); }, ms); } stop() { if (this.#handle !== null) { clearTimeout(this.#handle); this.#handle = null; } } } ``` Three rules follow from it: 1. **Never inspect the handle.** Store it, pass it back, compare it only against `null`. 2. **Track pending state yourself.** Set the field to `null` inside the callback and in the clear path; that is the only cross-host way to know whether a timer is outstanding, since neither host exposes a "still pending?" query. 3. **Keep handles out of serialized or shared state.** They are per-realm live references; they mean nothing outside the context that created them. ## A couple of shared facts worth knowing `clearTimeout` with an unknown or `undefined` argument is a harmless no-op in both hosts, so a defensive `if` before clearing is unnecessary — clear unconditionally if you like, then null the field. Handles are also **realm-scoped**. An id created inside an iframe or a worker means nothing to the outer page's `clearTimeout`, in exactly the same way a Node `Timeout` means nothing to another process. Passing handles across boundaries is always a mistake, whichever host you are on. ## How to answer this in an interview Lead with the concrete difference — number versus `Timeout` object — then immediately state the principle that makes it a non-issue: the handle is opaque, so the only correct operations are "give it back to clear" and "replace it with `null`". Mentioning `unref()` and process lifetime shows you have actually shipped Node code; mentioning serialization shows you have actually debugged this.

  • How do you test whether a timer is still pending, portably?
    You track it yourself. Neither host exposes a "pending" query on the handle, and inspecting the handle is not portable. Keep a field that holds the handle, set it to `null` both inside the callback and in your clear path, and treat `field !== null` as pending.
  • Why does a Node process sometimes refuse to exit after your work is finished?
    A pending timer keeps Node's event loop alive, so a forgotten long `setTimeout` or a repeating `setInterval` holds the process open. Clear it during teardown, or call `unref()` on a background timer so it no longer counts toward keeping the process running.
  • Is it safe to pass a timer handle to another context, such as a worker or an iframe?
    No. Handles are references scoped to the realm that created them: a browser id is only meaningful to the same global's `clearTimeout`, and a Node `Timeout` is a live object in one process. Send a message asking the owning context to cancel instead.

saying these in an interview costs you the question

  • Assumes setTimeout returns a number everywhere
  • Uses if (timerId) as a portable pending check
  • Serializes a timer handle into persisted state
  • Thinks clearTimeout throws when given undefined
  • Believes a browser timer id can be cleared from another frame

context