~/blog

setTimeout delays over 24.8 days fire immediately

published

#javascript#node#timers

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 delay argument 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:

RuntimeDelay passedWhat actually happens
Node.jsany value > 2147483647delay set to 1, TimeoutOverflowWarning on stderr
Browser2 ** 32 - 5000wraps to a negative number → fires immediately
Browser2 ** 32 + 5000wraps 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

References