DEV Community

delay.run
delay.run

Posted on

Don't use setTimeout for anything that matters: where delayed jobs should actually live

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.AfterFunc and 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_at column plus a poller, a queue with delayed delivery, or an external scheduler that calls you over HTTP.
  • My opinion: the database run_at column 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.
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode
// 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 *`);
Enter fullscreen mode Exit fullscreen mode

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

  1. Store the intent, not the closure. Save kind plus payload, so code changes between scheduling and execution don't break old jobs.
  2. Re-check state at run time. The trial-expiry job should confirm the user still hasn't upgraded before sending. Days pass. Things change.
  3. 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?

Top comments (0)