Short answer: for a background job queue, keep the long date in your database, wake a small scheduler on a short interval, and enqueue only the delayed retry work that is due. A queue's seven-day ceiling then becomes a dispatch detail instead of a business deadline. For a marketplace renewal reminder, that separation is the difference between “send on the contract date” and “hope a broker still remembers it.”
1. Pick the clock that owns the promise
Start with a decision table. The right row depends on the delivery guarantee you can actually explain to a buyer or seller.
| Pattern | Pick this when | Guarantee you can defend | Main cost |
|---|---|---|---|
| Queue delay only | The reminder is due within the queue's supported window | Best effort inside that window | A long delay may be rejected or capped |
| Database ledger plus cron scan | Dates can be weeks or months away | At-least-once dispatch after a due record is visible | You operate a scanner and an index |
| Partitioned time buckets | You have very high volume and predictable dates | At-least-once dispatch with bounded scan work | More moving parts and rebalancing |
| Managed workflow timer | You need durable timers and accept a platform dependency | Provider-defined retry and timer semantics | Less control over portability |
For a marketplace, I pick the ledger plus cron scan when a renewal deadline is a contractual date. The queue still handles fast retries, but it no longer pretends to be a calendar.
2. What should a Node.js scheduler record before it enqueues?
Record intent before transport. A row such as renewal_id, due_at, state, attempts, and an idempotency key is enough to begin. Put a unique constraint on the business event, not on a generated timestamp. Two scheduler processes may observe the same row; only one should win the claim, and a second delivery must be harmless.
The flow is easier to reason about as a sentence: database is the calendar; cron is the doorbell; the queue is the conveyor belt; the worker is the hand that sends the reminder. If the doorbell rings twice, the hand still sends once.
Dates move.
That is why the ledger, rather than an in-memory timer, owns the promise. A renewal can be postponed, cancelled, or moved to a new seller account while the scheduler is asleep; the next scan reads the current truth.
Here is a deliberately small claim-and-enqueue loop. The SQL shape is illustrative; use the transaction and locking syntax your database supports.
type Renewal = {
id: string;
dueAt: Date;
dispatchKey: string;
};
async function dispatchDueRenewals(now: Date, limit = 200): Promise<void> {
const renewals: Renewal[] = await db.transaction(async (tx) => {
const rows = await tx.query<Renewal>(
`UPDATE renewal_reminders
SET state = 'claimed', claimed_at = $1
WHERE id IN (
SELECT id FROM renewal_reminders
WHERE state = 'pending' AND due_at <= $1
ORDER BY due_at
FOR UPDATE SKIP LOCKED
LIMIT $2
)
RETURNING id, due_at AS "dueAt", dispatch_key AS "dispatchKey"`,
[now, limit]
);
return rows;
});
for (const renewal of renewals) {
await queue.publish({
type: 'renewal-reminder',
renewalId: renewal.id,
idempotencyKey: renewal.dispatchKey,
});
}
}
The claim is short-lived, so a crashed process leaves a visible recovery path: a reaper can return stale claimed rows to pending. That is an operational policy, not a hidden workaround. Set the cron interval from the promise you made. A five-minute scan cannot honestly promise second-level delivery.
3. How do delayed retry limits, cron enqueue, and Node.js failure modes fit together?
Separate two clocks: the business clock (due_at) and the retry clock (next_attempt_at). A failed send may retry in 30 seconds, then 5 minutes, while the renewal date may be 45 days away. Never encode that 45-day wait as one broker message when the broker documents a seven-day maximum for delayed messages.
The common failure is a “sleep until due” process. Deploys interrupt it. Autoscaling multiplies it. A daylight-saving transition can shift a local timestamp. Store UTC, convert only at the presentation edge, and test a date around both a month boundary and a DST change.
Keep the worker boring. It checks the idempotency key, loads the current renewal state, sends only when the reminder is still valid, and records the provider response. A cancellation after enqueue should suppress the send; a duplicate enqueue should produce one effect. Exactly-once delivery is a seductive label here—at-least-once delivery plus an idempotent effect is the tractable contract.
4. Which option is observable enough for a delivery guarantee?
Measure the path, not just the queue. Emit due_lag_seconds when a row is claimed, enqueue_lag_seconds when it reaches the queue, and send_lag_seconds when the worker finishes. Alert on age of the oldest pending row, claim-to-publish failures, and the count of stale claims. A queue depth of zero can look healthy while the database ledger is quietly aging.
Use a trace or correlation id that survives the cron process, publish call, and worker. Log the renewal id and dispatch key, but redact customer content. On-call needs one query that answers: was the reminder never claimed, claimed but not published, retried, or sent and later cancelled?
Make the dashboard tell the same story as the ledger. A useful panel shows the number of rows by state (pending, claimed, sent, cancelled) and a percentile for due lag. During an incident, compare those values with queue publish acknowledgements. If claimed rises while acknowledgements flatten, the fault is between the transaction and the broker. If acknowledgements rise but sent does not, inspect workers and provider responses. This sequence sounds obvious on paper, yet it is the difference between a focused rollback and restarting every consumer in the marketplace.
Three real tools illustrate different boundaries without deciding for you. Vercel Cron Jobs is an HTTP-triggered scheduler, so you still own the ledger and idempotency. Inngest documents durable function execution and retries, but adopting it makes its execution model part of your portability decision. BullMQ offers Node.js queue primitives, while a long delay still has to fit the deployment and storage assumptions behind that queue. Your guarantee comes from the whole path, not the logo on one component.
5. The catch: when should you choose something else?
The main limitation is the scan interval: this design is not suitable when a legal notice must arrive within a few seconds and a five-minute scan is unacceptable; use a timer system with a documented latency target and keep a durable audit record anyway. It is also a poor fit for millions of dates that change every second, where a bucketed scheduler or specialized workflow engine may reduce database contention.
The ledger adds schema, indexes, reaping, and an on-call page. That is the trade-off. I'm not sure a hosted timer gives you the portability your team wants, and your mileage will vary with its replay and cancellation semantics. Write those semantics into a test before migrating.
For the marketplace renewal case, the decision rule is simple: store the deadline durably, scan at a stated cadence, enqueue within the transport window, and make the worker idempotent. The seven-day limit is then a design input, not a surprise in production.
References
- https://vercel.com/docs/cron-jobs
- https://www.inngest.com/docs
- https://docs.bullmq.io/
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date
Top comments (0)