setTimeout 32-Bit Delay Trap: The Five-Year Reminder That Fires Right Now2026-08-24
This module schedules reminders for arbitrary future dates. It's used across the app: renewal reminders, subscription expiry alerts, "check back in a year" nudges. It passes every unit test — the timers fire, the callbacks run, the email gets sent. Then a QA engineer schedules a reminder for a five-year-old event and their inbox floods within seconds.
// Schedule a callback to fire after the given number of days
function scheduleReminder(days, callback) {
const ms = days * 24 * 60 * 60 * 1000;
console.log(`Reminder scheduled for ${days} days from now`);
return setTimeout(callback, ms);
}
// A parent schedules a reminder for their child's 18th birthday
const daysUntilBirthday = 365 * 5; // 5 years away
scheduleReminder(daysUntilBirthday, () => {
sendEmail('Happy 18th birthday!');
});
// An annual review, one year out
scheduleReminder(365, () => {
sendReview();
});
// Tests confirm both timers eventually fire the callback. Ship it.
Unit tests using days = 0.001 or days = 1 pass cleanly. In production, the birthday email arrives immediately. The annual review works fine. What broke?
The HTML spec pins the setTimeout delay to a signed 32-bit integer. The maximum representable value is 2^31 − 1 = 2,147,483,647 ms, which works out to about 24.855 days. Pass anything larger and the delay overflows. Every major browser and Node.js reacts the same way: the oversized delay is clamped or wrapped down to 1 ms, and the callback fires immediately.
Do the arithmetic on the birthday reminder:
365 × 5 × 24 × 60 × 60 × 1000 = 157,680,000,000 msThe 1-year reminder (31,536,000,000 ms) is also too large — it just happens to still be too large, so it fires immediately too. The tests pass because they use small values that stay under the 24.855-day ceiling. The bug lives entirely in the region the tests never explore.
Worse: no error is thrown. No warning is logged. The console dutifully prints "Reminder scheduled for 1825 days from now" a millisecond before the callback runs.
Don't use setTimeout for long-horizon scheduling. For anything that might exceed a few weeks, compute a target timestamp and either persist it (database, cron, job queue) or chain shorter timers:
const MAX_DELAY = 2_147_483_647; // 2^31 - 1
function scheduleReminder(days, callback) {
const targetTime = Date.now() + days * 24 * 60 * 60 * 1000;
function tick() {
const remaining = targetTime - Date.now();
if (remaining <= 0) {
callback();
} else {
setTimeout(tick, Math.min(remaining, MAX_DELAY));
}
}
tick();
console.log(`Reminder scheduled for ${new Date(targetTime).toISOString()}`);
}
For truly long-lived reminders (months, years), a process crash or reboot will erase the timer anyway. Persist the target timestamp in durable storage and let a scheduler or wake-on-timer job handle it. In-memory timers are for seconds and minutes — not birthdays.
setTimeout silently clamps delays over ~24.8 days to a single millisecond, so any long-horizon timer fires instantly — persist the target time instead.
