What does `setTimeout` return in a browser versus in Node, and what code breaks when you assume the two are the same?
answer
- handle types differ by host
- number here, object there
- opaque handle, hand it back
- null the field to mean not pending
- pending Node timer holds the process open
basics
~20 sBrowsers 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 sIn 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 lineslet 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
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.
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.
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.
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