The trial-expiry email was scheduled with setTimeout(sendEmail, 3 * 24 * 60 * 60 * 1000). It worked in testing. In production, the app deployed twice a day, and every deploy wiped every pending timer. Almost nobody got the email. The bug was only discovered when someone asked why trial conversions had dropped.
In-memory timers are promises made by a process that won't live long enough to keep them.
TL;DR
-
setTimeout,asyncio.sleep,time.AfterFuncand friends are lost on restart, deploy, crash and autoscale-down. - Anything delayed more than a few seconds, or anything you'd be upset to lose, needs durable storage.
- Options: a
run_atcolumn plus a poller, a queue with delayed delivery, or an external scheduler that calls you over HTTP. - My opinion: the database
run_atcolumn is underrated. It's the right answer for most apps until it clearly isn't.
Why timers fail in production
Things that kill an in-memory timer:
- deploys and restarts
- crashes and out-of-memory kills
- autoscaling removing an instance
- serverless functions that freeze after the response
- container platforms that recycle instances on their own schedule
There's also a JavaScript-specific trap. Delays above 2,147,483,647 ms (about 24.8 days) overflow, and the callback fires almost immediately:
setTimeout(() => console.log('fired'), 30 * 24 * 60 * 60 * 1000); // TimeoutOverflowWarning ... Timeout duration was set to 1. So "remind me in 30 days" with setTimeout sends the reminder now.
When in-memory is fine
Short, disposable delays where losing one costs nothing:
- debouncing a search box or a burst of writes
- retrying a request after 500 ms inside the same request handler
- a UI toast that disappears after 3 seconds
Rule of thumb: if the delay is longer than your shortest deploy interval, or the job has business value, don't keep it in memory.
Option 1: a run_at column and a poller
CREATE TABLE scheduled_jobs ( id bigserial PRIMARY KEY, kind text NOT NULL, payload jsonb NOT NULL, run_at timestamptz NOT NULL, status text NOT NULL DEFAULT 'pending', attempts int NOT NULL DEFAULT 0 ); CREATE INDEX ON scheduled_jobs (run_at) WHERE status = 'pending'; // enqueue await db.query( `INSERT INTO scheduled_jobs (kind, payload, run_at) VALUES ('trial_expiry_email', $1, now() + interval '3 days')`, [{ userId }] ); // poll every few seconds const { rows } = await db.query(` UPDATE scheduled_jobs SET status = 'running' WHERE id IN ( SELECT id FROM scheduled_jobs WHERE status = 'pending' AND run_at <= now() ORDER BY run_at LIMIT 50 FOR UPDATE SKIP LOCKED) RETURNING *`); Pros: survives everything, transactional with your business data (schedule the email in the same transaction that creates the trial), easy to inspect and cancel with SQL.
Cons: polling latency, you write the retry and cleanup logic, and very high volumes need care.
A real example of this pattern is in How to send a reminder 24 hours after signup without a queue.
Option 2: a queue with delayed delivery
Many job libraries and brokers support "deliver after N seconds": Redis-backed job queues, SQS delay (up to 15 minutes per message), RabbitMQ with a delay plugin, and so on.
Pros: built-in retries, concurrency control, dashboards.
Cons: another moving part. Delay limits vary (SQS caps at 15 minutes). Cancelling or rescheduling a specific job is often awkward. Redis-backed queues need persistence configured, or a Redis restart is just a slower setTimeout.
Option 3: an external scheduler that calls you
Instead of storing the job yourself, register an HTTP callback: "POST this payload to this URL at this time". Your app needs no worker process, which suits serverless platforms where nothing runs between requests. The trade-offs for serverless specifically are in How to run cron jobs on serverless.
Pros: no worker or poller to run, works from any stack, retries handled by the scheduler.
Cons: your endpoint must be public and authenticated, and you depend on an outside service. Use signed requests and handle duplicates, as covered in the previous post on fast webhook handlers.
Services like delay.run fall into this category, as do the scheduling features of some cloud platforms.
Comparison
setTimeout | DB run_at | Delayed queue | HTTP scheduler | |
|---|---|---|---|---|
| Survives deploy | no | yes | yes (if persisted) | yes |
| Long delays (days, months) | no | yes | varies | yes |
| Cancel / reschedule | in-process only | trivial SQL | often awkward | via API |
| Works on serverless | no | needs a poller | needs a consumer | yes |
| Extra infrastructure | none | none | broker | external service |
Whatever you pick: the same three rules
- Store the intent, not the closure. Save
kindpluspayload, so code changes between scheduling and execution don't break old jobs. - Re-check state at run time. The trial-expiry job should confirm the user still hasn't upgraded before sending. Days pass. Things change.
- Assume it runs twice. Every option above can deliver a job more than once on retries or failover. The at-least-once post has the patterns.
My take
Reaching for a queue for the first delayed job in an app is usually premature. A run_at column handles thousands of jobs a minute on ordinary Postgres, lives next to your data and can be debugged with SELECT. Move to a queue or an external scheduler when polling latency, volume or a serverless platform makes the table a real problem.
Over to you
What's your default for delayed jobs: database table, queue, or an external scheduler? And has anyone else shipped the 24.8-day setTimeout overflow bug to production?
