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.
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 entirelyBackground-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));
});| setTimeout | Countdown Timer API | |
|---|---|---|
| Survives a restart | No | Yes |
| Maximum delay | 24 days 20 hours | One year per timer |
| Behaviour past the maximum | Fires immediately, silently | Rejected with a validation error |
| Survives a deploy | No | Yes |
| Works on serverless | Only within one invocation | Yes |
| Retries if the handler fails | No | Five retries over ~15 hours |
| Visible to operators | No | Delivery record per firing |
| Can show a countdown to a user | You build it | Hosted page, embed, and email GIF |
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.
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.