Countdown Timer API vs setTimeout

setTimeout is the right tool for “do this in 300 milliseconds”. It is the wrong tool for “this trial ends in fourteen days”, and the reasons are specific rather than a matter of taste: it lives in one process's memory, it silently overflows past 24.8 days, and on the client it counts against a clock the user controls. This page is about each failure, and about the case where setTimeout is still exactly right.

It dies with the process

This is the one that reaches production, because it works perfectly on a developer machine that never restarts during a test.

The code everyone writes first

// Looks fine. Works in development. Fails in production.
function startTrial(customer) {
  setTimeout(() => {
    downgradeAccount(customer.id);
  }, 14 * 24 * 60 * 60 * 1000);   // 14 days
}

// Deploy, restart, crash, scale-to-zero, or a new container —
// the callback is gone and nothing records that it was lost.

A pending setTimeout is a closure on a heap. It has no representation outside that process, so anything that ends the process ends the timer: a deploy, a crash, an out-of-memory kill, a container being rescheduled, an autoscaler removing an instance, or a serverless runtime freezing between invocations.

Nothing is logged when this happens, because nothing failed. There is no error, no dead letter, and no record that a callback was ever pending. You find out when a customer keeps a paid feature for a month.

On serverless this is not an edge case, it is the default. A Lambda or Vercel function that returns a response is frozen almost immediately — a setTimeout longer than the remaining execution window will simply never run.

It silently overflows past 24.8 days

The delay is stored as a signed 32-bit integer. Exceed it and the value wraps, and the callback fires immediately instead of a month later.

The 32-bit trap

// The 32-bit trap. setTimeout stores the delay as a signed
// 32-bit integer: anything over 2,147,483,647 ms overflows.
const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000;   // 2,592,000,000

setTimeout(() => console.log("fires in 30 days"), THIRTY_DAYS);
// Actually fires IMMEDIATELY. The value wraps and is treated as 1.
//
// Max safe delay: 2147483647 ms = 24 days, 20 hours, 31 minutes.
// No warning. No error. Just an instant callback.

This behaviour is specified and consistent — Node.js emits a warning in some versions, browsers do not. A 30-day trial, a 90-day retention window, or an annual renewal reminder all cross the line. The bug does not look like a timing bug; it looks like the action happening instantly on signup, which sends people hunting in entirely the wrong place.

On the client it counts the wrong clock

A browser setTimeout measures against the visitor's system clock, which you do not control and cannot trust.

Five ways the client version is wrong

// The client-side version, which is a different bug.
const remaining = deadline - Date.now();   // the VISITOR's clock

setTimeout(showExpired, remaining);
// - System clock is wrong  -> fires early or late
// - Laptop lid closed      -> timer suspends, fires late
// - Tab backgrounded       -> throttled to >=1s, then >=1min
// - User changes the clock -> deadline beaten deliberately
// - Page refreshed         -> timer gone entirely

Background-tab throttling alone breaks most countdown UIs: after a few minutes hidden, the tab's timers drop to roughly one tick per minute, so a countdown that looked smooth reappears having jumped. And on anything with money attached, a visitor who moves their system clock forward walks past a deadline that only ever existed in their browser.

What replaces it

The deadline moves out of process memory and becomes a record. Whatever is running in fourteen days receives the callback — it does not have to be the process that created it, or even the same deployment.

A deadline that outlives the process

// The deadline outlives the process that created it.
await call("/timers", {
  method: "POST",
  headers: { "Idempotency-Key": `trial_${customer.id}` },
  body: JSON.stringify({
    name: `Trial — ${customer.email}`,
    type: "personalized",
    duration_seconds: 14 * 24 * 60 * 60,
    external_user_id: customer.id,
  }),
});

// Fourteen days later, in whatever process happens to be running:
app.post("/webhooks/countdownshare", (req, res) => {
  res.sendStatus(200);
  queue.add("downgrade-account", JSON.parse(req.body));
});
setTimeoutCountdown Timer API
Survives a restartNoYes
Maximum delay24 days 20 hoursOne year per timer
Behaviour past the maximumFires immediately, silentlyRejected with a validation error
Survives a deployNoYes
Works on serverlessOnly within one invocationYes
Retries if the handler failsNoFive retries over ~15 hours
Visible to operatorsNoDelivery record per firing
Can show a countdown to a userYou build itHosted page, embed, and email GIF
Note the second and third rows together. A one-year maximum is not the interesting part — being rejected rather than silently wrapping is. A deadline outside the supported range should be an error at creation, not a callback that fires at the wrong time a month later.

When setTimeout is correct

Most of the time, in fact. It is a good primitive being asked to do a job it was never designed for.

When setTimeout is the better choice

  • Sub-second and second-scale delays — debouncing, throttling, animation timing, retry backoff.
  • Anything scoped to one request or one page view, where losing it on a restart is correct behaviour.
  • Driving the visual tick of a countdown between server reads. This is the right division of labour, not a compromise.
  • A local development script or a one-off task where durability genuinely does not matter.
  • Polling intervals inside a long-lived worker you already supervise.

The dividing line is ownership. If setTimeout is animating a number, it is doing its job. If it is the only record that something must happen, it is a single point of failure with no alarm attached.

The pattern that uses both correctly

Read the authoritative remaining time once, then let setTimeout or setInterval animate between reads. The server owns the truth; the browser owns the smoothness.

Server decides

The status endpoint returns remaining time and the server clock. Measure the skew once and count against a corrected baseline.

Client animates

A one-second interval keeps the display alive. If it drifts or a tab is throttled, the next re-sync corrects it.

Server enforces

At the moment the deadline gates something real, re-check server-side. Never trust the value the browser submitted.

Read remaining time has the working version of this, including measuring clock skew from server_time and re-syncing on visibilitychange so a restored tab corrects itself.

Common questions

What is the maximum setTimeout delay?

2,147,483,647 milliseconds — 24 days, 20 hours, 31 minutes and change. It is the largest signed 32-bit integer. Pass anything larger and the value overflows and the callback fires almost immediately, with no error and no warning. This is specified behaviour in browsers and Node.js alike.

Does setTimeout survive a server restart?

No. It lives in the memory of one process. A deploy, a crash, an OOM kill, a container reschedule, or a serverless function freezing all discard it silently. Nothing is logged, because nothing failed — the process simply stopped existing before the callback was due.

Is setTimeout accurate?

It guarantees a minimum delay, not an exact one. The callback runs when the event loop next gets to it, so a busy loop or a blocking operation pushes it back. In a background browser tab it is throttled to at least one second, and after a few minutes to roughly once per minute.

Can I just store the deadline in the database and use setTimeout for the UI?

Yes, and that is the correct pattern — the database or the API is the authority, and setTimeout only drives the visual tick between reads. The mistake is letting setTimeout own the decision rather than the animation. Anything transactional should be re-checked server-side at the moment it matters.

Related

Give the deadline somewhere to live

Sandbox is free with any account. Create a timer, restart your server, and watch the callback still arrive.