JavaScript's setTimeout 32-Bit Delay Trap: The Five-Year Reminder That Fires Right Now

2026-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 Bug

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:

The 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.

The Fix

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.

Key Takeaway: setTimeout silently clamps delays over ~24.8 days to a single millisecond, so any long-horizon timer fires instantly — persist the target time instead.

All newsletters