setTimeout delays over 24.8 days fire immediately
published
TL;DR
setTimeout converts delay to a signed 32-bit integer. The maximum is
2147483647 ms — about 24.855 days. Pass more and the value overflows:
in Node the timer fires on the next tick, in browsers it fires after whatever
the wrapped-around value happens to be. Node warns with
TimeoutOverflowWarning; browsers say nothing at all. Don’t schedule long
delays with a timer — store a target timestamp and re-check.
The problem
This looks like it schedules a cleanup in 30 days:
const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000; // 2592000000
setTimeout(runCleanup, THIRTY_DAYS);
It runs immediately. Node v26.2.0:
$ node -e "const t0=Date.now(); setTimeout(()=>{console.log('FIRED after', Date.now()-t0, 'ms')}, 2147483648)"
(node:42584) TimeoutOverflowWarning: 2147483648 does not fit into a 32-bit signed integer.
Timeout duration was set to 1.
FIRED after 4 ms
One millisecond over the limit and the delay becomes 1. The warning goes to
stderr, which in most deployments means it lands in a log nobody reads while
the callback quietly runs 30 days early.
2147483647 is fine and produces no warning. 2147483648 is not. The cliff
is exactly one millisecond wide.
Why it happens
The delay argument is specified as a long — a signed 32-bit integer. MDN
puts it plainly:
The
delayargument is converted to a signed 32-bit integer, which limits the value to 2147483647 ms, or roughly 24.8 days. Delays of more than this value will cause an integer overflow.
Node and browsers handle the overflow differently, and the browser case is the nastier of the two because the result is modular arithmetic, not a clamp:
| Runtime | Delay passed | What actually happens |
|---|---|---|
| Node.js | any value > 2147483647 | delay set to 1, TimeoutOverflowWarning on stderr |
| Browser | 2 ** 32 - 5000 | wraps to a negative number → fires immediately |
| Browser | 2 ** 32 + 5000 | wraps to 5000 → fires after ~5 seconds |
The 2 ** 32 + 5000 row is the one that burns people. A timer set for roughly
49.7 days doesn’t fire early in an obvious “something is broken” way — it fires
after five seconds, a plausible-looking interval, so the bug reads as a logic
error somewhere else entirely.
setInterval has the same 32-bit delay, so a long polling interval overflows
into a hot loop.
What to do
Store the deadline, not the delay. Wake up on a bounded schedule and compare against the clock:
const MAX_DELAY = 2147483647;
function scheduleAt(timestamp, fn) {
const handle = { id: null, cancelled: false };
(function hop() {
if (handle.cancelled) return;
const remaining = timestamp - Date.now();
if (remaining <= 0) return void fn();
// chain in ≤24.8-day hops, recomputing the remainder from the
// absolute deadline each time so drift doesn't compound
handle.id = setTimeout(hop, Math.min(remaining, MAX_DELAY));
})();
return {
cancel() {
handle.cancelled = true;
clearTimeout(handle.id);
},
};
}
const job = scheduleAt(Date.now() + 30 * 24 * 60 * 60 * 1000, runCleanup);
// job.cancel() works at any point in the chain
Returning a raw Timeout here would be a trap: the handle from the first
setTimeout is replaced on every hop, so clearTimeout on it stops being able
to cancel anything after the first ~24.8 days — precisely the range the helper
exists to cover. The cancel() closure above stays valid for the whole chain.
This is correct for the overflow, but note it is still an in-memory timer: a process restart loses it. For anything that has to survive a deploy, persist the target timestamp and check it on startup and on a short interval, or hand the job to a scheduler that owns durable state — cron, a queue with a visibility delay, or a database column you poll.
If you only need the guard, assert instead of hoping:
if (delay > 2147483647) {
throw new RangeError(`delay ${delay}ms exceeds the 32-bit setTimeout limit`);
}
Caveats
- The limit is on the delay, not on wall-clock scheduling in general. A chain of sub-limit timeouts is fine.
- Node’s behaviour (
delay = 1plus a warning) is not in the HTML spec — it is Node’s choice. Don’t rely on the warning existing in other runtimes; browsers, Deno and Bun are not obliged to print anything. - Timer drift accumulates across a long chain. Recomputing
remainingfrom the absolute timestamp on every hop, as above, avoids compounding it — computing the next hop from the previous delay does not. - Suspended or throttled tabs stretch timers well past their nominal delay. Long client-side timers are unreliable independently of this overflow.
unref()ing a long timer in Node still leaves the overflow in place; it only changes whether the timer keeps the event loop alive.