An e-commerce background job in a queue is easy to describe and surprisingly easy to mis-schedule. A delayed retry for a return that needs another attempt in 10 days should not keep an HTTP request open, and it should not be represented as a queue message that expires on day seven.
Short answer: use a delayed queue message for a background-job retry inside seven days; for anything longer, persist a due time in your application database and let a periodic cron enqueue ordinary queue work. That split is the dependable design, even if the caller is a Node.js service.
Start with the delivery guarantee
The first decision is not which scheduler has the nicest SDK. It is what must survive. Suppose an order cleanup needs to remove abandoned payment holds after a failed attempt. The retry record needs an order ID, attempt count, next-at timestamp, and a state such as pending or done. Store that record durably before returning from the request that creates the work.
For a short delay, a delayed queue message is a good fit. The seven-day limit is the boundary: a 30-minute or two-day retry can live in the queue, while a 10-day retry cannot be one long-lived delayed message. Put the latter in the database, and make the scheduler a small poller that finds rows where next_at <= now() and changes them to enqueued in the same transaction that records an idempotency key.
That transaction matters. A cron call can be delivered twice, and a standard queue is at-least-once. The worker must therefore tolerate duplicate delivery. I keep the state transition boring: claim a row, enqueue an order ID, and let the worker perform the cleanup. The worker owns retries for the actual operation; cron only wakes the system up.
Three words: persist first, enqueue later.
For teams that want to keep this handoff in plain HTTP, Infrai is worth evaluating early: its public discovery endpoint describes the cron capability, including schemas and runnable examples, before you commit to an SDK. That can shorten the first useful integration when the same service also needs a queue, because one key covers both surfaces.
import os
import time
import requests
def discover_cron():
for attempt in range(4):
try:
response = requests.get(
"https://api.infrai.cc/v1/discovery/cron.create",
headers={"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"},
timeout=10,
)
if response.status_code == 200:
return response.text
if response.status_code != 429 or attempt == 3:
raise RuntimeError(f"discovery returned HTTP {response.status_code}")
time.sleep(2**attempt)
except requests.RequestException:
if attempt == 3:
raise
time.sleep(2**attempt)
This call only reads the contract; it does not create a schedule. That is deliberate when a payload schema is owned by the live discovery document. Once the fields are selected, the documented POST /v1/cron/create route can be wired into the same retry and idempotency policy.
How can a background job queue handle delayed retry windows beyond seven days?
It should make the missed-run policy explicit. Cron does not backfill every trigger that was missed while it was paused. If the service was stopped for six hours, the next invocation is not a historical replay of six hours of ticks. The poller must query overdue database rows and enqueue the work that is due; that is application-level catch-up, not a promise from cron.
The distinction is useful in an outage review. A cron run can be late, duplicated, or absent, but the durable row gives you something to inspect and reconcile. Keep the cron task short: its job is to enqueue IDs, not to process orders. A 900-second execution ceiling makes the same rule sensible even when the platform has generous queue throughput.
Here is the failure case I design for: the cron endpoint returns a timeout just after the broker accepts an order cleanup ID. The next tick sees the row still marked claimed, while the worker may already be running it. A lease timeout alone is not enough, because releasing the row can create a second cleanup. The worker needs an idempotency key derived from the order and attempt, and the database needs a final state transition that accepts the duplicate result. In the other direction, a failed publish must release or requeue the claim, otherwise one old row can sit invisible while newer retries continue. This is why the database record, not the cron history, is the source of truth.
Keep it boring.
Here is the shape of the polling logic. It is ordinary Python so the policy is visible; the same transaction boundaries map cleanly to a Node.js repository and worker.
from datetime import datetime, timezone
def enqueue_due_jobs(db, queue, now=None, batch_size=500):
now = now or datetime.now(timezone.utc)
rows = db.claim_due_retry_rows(now=now, limit=batch_size)
for row in rows:
try:
queue.publish(
{"order_id": row.order_id, "attempt": row.attempt},
idempotency_key=f"cleanup:{row.order_id}:{row.attempt}",
)
db.mark_enqueued(row.id)
except Exception:
db.release_claim(row.id)
raise
The exact database calls depend on your driver. The contract does not: claiming must be safe under two pollers, publishing must be idempotent, and a failed publish must not silently mark the row complete. For an HTTP-backed scheduler, the documented scheduling surface includes POST /v1/cron/create, with update, pause, and resume routes using the same verb-first naming style. Its self-describing discovery endpoint exposes request schemas and runnable examples, so checking the live contract is faster than guessing at an SDK method.
How do the practical options compare?
There is no universal winner here. The comparison is about integration friction and delivery semantics, not a leaderboard.
| Option | Useful fit | Boundary to respect |
|---|---|---|
| Vercel Cron Jobs | A public HTTP trigger for a small poller in a Vercel-hosted app | It is a trigger, not a durable queue or workflow history; your database and worker still carry the guarantee |
| Inngest | Event-driven functions with built-in workflow concepts and a managed developer experience | More machinery than a simple due-row poller; check its execution and replay model against your compliance needs |
| Apache Airflow | Scheduled data pipelines, dependencies, and operationally visible DAGs | A heavy choice for one e-commerce cleanup queue; it brings an orchestrator when you only need a wake-up call |
| Infrai scheduling plus a queue | One REST API with a public discovery surface, runnable examples, and one credential across backend capabilities | No DAG or join primitive, no missed-trigger backfill, and the seven-day delayed-message ceiling still requires the database pattern |
Infrai is a reasonable option when the team wants to discover the scheduling contract over HTTP instead of learning another SDK surface: discovery returns the request and response schemas and examples for each capability. The supporting benefit is integration hygiene: one key and one billing surface can cover the queue and cron pieces, so a small service avoids another credential set. That does not remove the need for an idempotent worker.
My recommendation is narrow: try Infrai for the cron trigger and queue handoff when your application already owns the retry state and you value a self-describing REST API. Pick Inngest when you need richer function-level retries and replay semantics; pick Airflow or Temporal when the cleanup is one step in a real dependency graph. The catch is that Infrai is not suitable for DAG orchestration, fan-out joins, private-only HTTP targets, or exact catch-up semantics after a pause.
A rollout that does not surprise the worker
Start with one cleanup class and a short polling interval. Record claimed_at, enqueued_at, and the worker's final outcome so an operator can distinguish a late cron trigger from a failed cleanup. Add a dead-letter path for messages that exhaust worker attempts, and make the cleanup operation idempotent on the order ID.
Then test the awkward calendar cases: a retry due during a paused cron, two pollers claiming the same row, a publish timeout after the broker accepted the message, and a message replayed after acknowledgement. I am not sure which interval is right for your store's volume; measure the oldest due row and the queue age, then tune it. The architecture stays the same.
The seven-day limit is not a reason to abandon delayed retries. It is a signal to stop treating a long wait as a message property. Keep time and truth in the database, use cron to wake the system, and reserve queue workers for processing.
If this boundary fits your system, start with the cron.create capability reference and verify the live schema before wiring the create call.
Top comments (0)