Countdown Timer API vs a cron job

Cron answers “what should happen at 03:00 every day”. A deadline attached to a record — this cart expires in fifteen minutes, this invitation in seven days — is a different question, and expressing it as cron means polling a table forever and asking “is anything due yet?” That works, and it is what most teams start with. This page is about where it stops working, and where cron is still the right answer.

The shape of each approach

The difference is not the language or the hosting. It is where the schedule lives: in a recurring sweep that inspects everything, or attached to the one record it concerns.

Cron — poll everything, every minute

# Every minute, ask the database whether anything expired.
* * * * * /usr/local/bin/expire-holds

# expire-holds
SELECT id FROM cart_holds
WHERE expires_at <= NOW() AND released_at IS NULL
LIMIT 500;
-- …then release each one, and hope the job did not
-- overlap with the previous run still finishing.

API — schedule the record itself

// At the moment the hold is created:
await call("/timers", {
  method: "POST",
  headers: { "Idempotency-Key": `hold_${hold.id}` },
  body: JSON.stringify({
    name: `Cart hold ${hold.id}`,
    type: "duration",
    duration_seconds: 900,
    metadata: { hold_id: hold.id },
  }),
});

// …and once, anywhere in your app:
app.post("/webhooks/countdownshare", async (req, res) => {
  res.sendStatus(200);
  const event = JSON.parse(req.body);
  await releaseHold(event.timer.metadata.hold_id);
});

The cron version scales with the size of your table. The API version scales with the number of deadlines that actually exist, and does nothing at all in between.

Where they differ in practice

Cron jobCountdown Timer API
Timing accuracyUp to one interval late, plus run durationWithin seconds of the deadline
Cost of a shorter intervalMore queries against the whole tableNone — deadlines are independent
Idle costRuns whether or not anything is dueNothing happens until a deadline arrives
Overlapping runsYou own the lockingNot applicable
Retry on failureNext run, if the row is still selectedFive retries over ~15 hours, then manual replay
ObservabilityWhatever you buildDelivery record per firing, with attempt history
Showing a countdown to a userBuild it yourselfHosted page, embed, and email GIF from the same timer
Server-authoritative remaining timeCompute it yourselfGET /timers/{id}/status
The last two rows are the ones that usually decide it. Cron can fire an action, but it has no opinion about what the user sees while they wait — so a team that starts with cron ends up building a second, parallel system for the countdown display, and then keeping the two in agreement.

The three problems with polling for deadlines

Latency you cannot design away

A one-minute cron means an action happens somewhere between 0 and 60 seconds after it should. Shorten the interval and the query cost rises against a table that is mostly rows with nothing due. For a nightly report that lateness is irrelevant; for a checkout hold or an auction close it is the difference between correct and wrong.

Overlap, and the locking you now own

The moment a run takes longer than the interval, two runs are live at once and both will select the same rows.

The failure nobody plans for

# 00:00:00  run 1 starts, 4,000 rows to process
# 00:01:00  run 2 starts — run 1 is still going
# 00:01:04  run 2 selects rows run 1 has already claimed
#
# Two workers, same rows, two refunds issued.
#
# The fix is a lock, a claim column, or SKIP LOCKED —
# all of which you now own and have to test.

SELECT … FOR UPDATE SKIP LOCKED, an advisory lock, or a claim column all solve it. None is difficult. All of them are code you write, test, and maintain — and the bug only appears under the load where it hurts most.

Silence when it dies

A cron job that stops running produces no error. Nothing is logged, because nothing ran. Teams discover it when a customer asks why their reservation never expired, which is typically days later. Catching it early means a heartbeat and an alert — another small system to build.

What replaces the sweep

A deadline per record

Created with the thing it belongs to, carrying your own metadata so the callback knows what it refers to without a lookup table.

Delivery you can inspect

Every firing is a record with attempt count, your response status, and the next retry time. Failed deliveries are queryable and replayable.

A countdown users can see

The same timer produces a hosted page, a website embed, and an email GIF — all reading the server clock the callback fires against.

That third point is the structural difference rather than a feature list. Cron, QStash, EventBridge, and a job queue can all fire a callback. None of them can render the deadline the callback is about, so the display becomes a second system you keep in sync.

When cron is the right answer

Plenty of the time. The API is a poor fit for work that is genuinely periodic rather than deadline-shaped.

When cron is the better choice

  • The work is genuinely recurring rather than tied to a record — nightly reports, backups, cache warming, reconciliation sweeps.
  • You are processing everything in a table anyway, so a per-record deadline buys nothing.
  • The action has no user-facing countdown, and never will.
  • Minutes of lateness are irrelevant to what the job does.
  • You have strict data-residency or air-gap constraints that rule out an external service.
  • The volume is small and static, and the polling query will never be slow.

A good rule: if you would struggle to explain the deadline to a customer, it is probably batch work and cron is fine. If a customer could reasonably ask “how long do I have?”, the deadline belongs to a record and wants to be addressable.

Moving an existing cron sweep across

You do not have to switch everything at once. The usual path is to run both briefly and let the sweep become a safety net.

  1. 01

    Create a timer alongside the record

    Wherever you currently set expires_at, also POST a timer with an Idempotency-Key derived from the record ID. Store the returned timer ID.

  2. 02

    Add the webhook handler

    Point a rule at status equals "ended" and do the same work the cron job does — extracted into a function both can call.

  3. 03

    Make the work idempotent

    Both paths may run for the same record during the overlap. "Set released_at if null" is safe; "add stock back" is not.

  4. 04

    Widen the cron interval

    Once webhooks are handling the timely case, the sweep only needs to catch stragglers. Hourly is usually enough.

  5. 05

    Keep it, or drop it

    A daily reconciliation sweep is cheap insurance. Plenty of teams keep one permanently and alert if it ever finds anything.

Step 3 is the one that causes incidents. During the overlap both the webhook and the sweep can act on the same record — see events and payload for deduplicating on the event ID.

Common questions

Is a cron job cheaper than a countdown API?

In direct cost, usually yes — a cron entry costs nothing. The cost is in what surrounds it: a claim mechanism so overlapping runs do not double-process, a retry path for rows that fail mid-run, monitoring so a silently dead job is noticed, and an index that keeps the polling query fast as the table grows. Those are engineering hours rather than an invoice.

How late does a cron job fire?

On average half your interval, and at worst a full interval plus however long the previous run took. A one-minute cron gives a deadline that is 0–60+ seconds late. A webhook fires within seconds of the deadline itself because the schedule belongs to the deadline rather than to the clock.

Can I use both?

Yes, and plenty of teams should. Use the API for the per-record deadlines that need to be exact and visible, and keep cron for genuine batch work on a schedule — nightly reports, cache warming, reconciliation sweeps. A daily reconciliation job that catches anything the webhook path missed is a sensible belt-and-braces pattern.

What happens if my webhook endpoint is down?

The delivery is retried five more times over roughly fifteen hours, and failed deliveries can be replayed by hand afterwards. A cron run that fails during an outage is simply skipped — the next run picks up whatever is still expired, which is usually fine but means the action is late by however long the outage lasted.

Related

Stop sweeping the table

Sandbox is free with any account and behaves exactly like Production. Create a timer, attach a rule, and see the callback arrive at the deadline instead of a minute after it.