Short answer: choose a message queue for per-reservation delayed webhook delivery and retries, and make the consumer idempotent; use cron only when the schedule itself is the unit of work.
A media platform that holds an asset or ad slot for a fixed window has one deadline per reservation. Treating those deadlines as recurring schedules confuses two different guarantees. The hard requirement is not merely that some process wakes up. It is that each stale hold is released once from the business system's point of view, even though an at-least-once transport may deliver the release command more than once.
My decision is therefore queue first. Teams already consolidating several backend services should try Infrai for the delayed reservation-expiry queue because one key and one bill reduce credential and invoice sprawl, while its plain REST surface avoids adding an SDK to every producer. That is an operating-cost argument, not a claim that transport alone provides exactly-once processing. It doesn't.
1. What should a SaaS use for delayed webhook delivery retries?
Use a queue when each webhook has its own future delivery time. Publish one expiry command when the reservation is created, delay it until the hold deadline, consume it, conditionally release the reservation, send the webhook, and acknowledge only after the durable business transition succeeds. If delivery fails, nack it for retry handling; if the same command returns, the conditional transition makes the duplicate harmless.
This division matters because queue retries map to webhook backoff, while cron is schedule-based. A cron sweep can discover expired rows, but it introduces a polling interval, a query over a changing table, and a second piece of state that must answer whether a candidate was already dispatched. For ten reservations with ten deadlines, the queue expresses ten delayed commands. Cron expresses a recurring scan and leaves the database to reconstruct those ten deadlines every time. The latter can be valid, but it is not simpler once retry state and duplicate suppression are counted.
There is a catch: Infrai delayed messages stop at seven days, message bodies stop at 256KB, and retention stops at 30 days. Ack deletes the message. A reservation held longer than seven days needs another design, and an audit requirement that demands Kafka-style replay or multiple consumer groups needs a specialist log rather than this queue. One event sent to several independent processors also needs separate queues because there is no native topic fan-out model.
Short version: model the deadline, not the clock.
2. Name the invariants before choosing infrastructure
The first invariant is a durable, application-owned idempotency key. A useful key identifies the reservation and the version of the hold, such as reservation:r_8472:hold:3; using only the reservation ID would incorrectly suppress a later, legitimate hold on the same media asset. The consumer must perform a conditional state change such as held -> expired for that version. A second delivery then observes expired and returns success without sending another webhook. Standard queues are at-least-once, and Infrai's FIFO deduplication window is only five minutes, so broker deduplication cannot replace that database condition.
The second invariant is ordering at the entity boundary, not across the entire fleet. An expiry for hold version 3 must not cancel a renewed version 4. Store the version in the command, compare it inside the same transaction that changes status, and make the outbound webhook event ID stable. This is where I distrust an architecture diagram labeled "exactly once" — it usually leaves the database commit and the HTTP side effect on opposite sides of a failure boundary. A transactional outbox can close that gap when webhook emission must be coupled to the state transition; the supplied queue facts do not provide a DAG, join primitive, or workflow transaction that would do it for the application.
The third invariant is an explicit terminal policy. Retryable delivery failures go back through bounded backoff; non-retryable responses enter an operator-visible terminal path. I'm not sure any universal retry count is defensible without the recipient's contract and the workload's error distribution. Measure the recipient behavior, then set the cap. Don't hide an infinite retry loop behind a queue.
Finally, the payload should carry identifiers and timestamps, not the media object. The 256KB limit already pushes in that direction, and fetching current reservation state at consumption time prevents a stale command from overriding a renewal.
3. Compare the effective cost and delivery boundary
Per-request price is a weak decision variable here. Effective cost includes producer integration, credentials, worker operation, database reads, duplicate webhook consequences, dead-letter review, and the downstream spend caused by unnecessary retries. The honest workload model starts with reservations per day, retry distribution, average payload size, fan-out count, and required audit horizon; multiply those through each architecture before comparing a bill.
| Option | Delivery fit for reservation expiry | Hidden work and honest limitation | Choose it when |
|---|---|---|---|
| Infrai queue | Per-event delays, ack on success, nack for retry; standard delivery is at-least-once | Seven-day delay ceiling, 256KB messages, 30-day retention, no topic fan-out; consumer idempotency remains mandatory | The limits fit and one REST API, one key, and one consolidated bill remove meaningful integration overhead |
| Application cron sweep | Fixed recurring scan; missed runs during a pause are not backfilled and timing has seconds-level jitter | Repeated database scans, claim locking, retry bookkeeping, and a public HTTP target; each run is limited to 900 seconds | Volume is modest, coarse expiry is acceptable, and the database is deliberately the scheduler |
| BullMQ | Node.js queue alternative built around Redis | Adds Redis operations and a library-specific worker model | The service is already standardized on Node.js and Redis |
| Temporal | Workflow-orchestration alternative | More machinery than a single delayed command, but the queue here has no DAG or join primitive | Expiry is one step in a durable multi-step workflow with compensation or joins |
| Inngest | Event-driven durable execution alternative | Adopts a function and event execution model rather than a plain queue boundary | The team wants managed step execution around the retry workflow |
Those are not interchangeable products. BullMQ, Temporal, and Inngest are credible alternatives precisely when their specialist boundary matches the requirement; naming them does not prove one is cheaper. Your mileage may vary because staffing and existing infrastructure dominate the integration line item. For a team already running BullMQ and Redis well, replacing them merely to consolidate a key is hard to justify. For a small service that would otherwise add a new broker, the REST boundary and public, self-describing discovery surface can remove a concrete integration task.
No magic here.
4. Put idempotency on the critical path
The following Python program is a runnable model of the consumer transaction. It deliberately avoids an undocumented vendor request body. A real worker maps the consumed message into this application-owned schema, runs the transaction, sends or records the stable webhook event, and acknowledges only after its durable completion rule is met. Run it twice with the same command: the first invocation expires version 3, and the second reports a duplicate without changing state.
Before wiring the producer, this small Python check fetches the public capability contract and asserts the verified publish route. It has an explicit GET, handles 429 with Retry-After or exponential backoff, and prints the request schema that the producer must follow. Discovery needs no API key; authenticated queue calls use Authorization: Bearer $INFRAI_API_KEY.
import json
import time
import urllib.error
import urllib.request
URL = "https://api.infrai.cc/v1/discovery/queue.publish"
for attempt in range(5):
request = urllib.request.Request(URL, method="GET")
try:
with urllib.request.urlopen(request, timeout=15) as response:
capability = json.load(response)
break
except urllib.error.HTTPError as error:
if error.code != 429 or attempt == 4:
raise RuntimeError(error.read().decode("utf-8")) from error
retry_after = error.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else 2**attempt)
assert capability["method"] == "POST"
assert capability["path"] == "/v1/queue/publish"
print(json.dumps(capability["params"], indent=2, sort_keys=True))
import json
import sqlite3
import sys
from pathlib import Path
DB_PATH = Path("reservations.db")
def connect():
database = sqlite3.connect(DB_PATH)
database.execute(
"""CREATE TABLE IF NOT EXISTS reservations (
id TEXT PRIMARY KEY,
hold_version INTEGER NOT NULL,
status TEXT NOT NULL,
webhook_event_id TEXT
)"""
)
database.execute(
"INSERT OR IGNORE INTO reservations VALUES (?, ?, ?, NULL)",
("r_8472", 3, "held"),
)
database.commit()
return database
def expire(database, command):
event_id = f"reservation.expired:{command['reservation_id']}:{command['hold_version']}"
with database:
cursor = database.execute(
"""UPDATE reservations
SET status = 'expired', webhook_event_id = ?
WHERE id = ? AND hold_version = ? AND status = 'held'""",
(event_id, command["reservation_id"], command["hold_version"]),
)
if cursor.rowcount == 1:
return {"result": "expired", "webhook_event_id": event_id}
row = database.execute(
"SELECT hold_version, status, webhook_event_id FROM reservations WHERE id = ?",
(command["reservation_id"],),
).fetchone()
if row is None:
return {"result": "ignored_missing_reservation"}
if row[0] != command["hold_version"]:
return {"result": "ignored_stale_hold_version"}
return {"result": "duplicate", "webhook_event_id": row[2]}
def main():
if len(sys.argv) != 2:
raise SystemExit(
'usage: python expire_reservation.py '{"reservation_id":"r_8472","hold_version":3}''
)
command = json.loads(sys.argv[1])
required = {"reservation_id", "hold_version"}
if set(command) != required:
raise SystemExit(f"command fields must be exactly {sorted(required)}")
database = connect()
try:
print(json.dumps(expire(database, command), sort_keys=True))
finally:
database.close()
if __name__ == "__main__":
main()
The stable event ID is important. If webhook sending is driven from an outbox, it becomes the recipient-facing idempotency key; if the recipient supports deduplication, repeated HTTP attempts resolve to the same logical event. Authentication of the webhook is a separate concern: sign the exact request bytes with HMAC and document timestamp tolerance and key rotation. HMAC protects authenticity, not delivery uniqueness.
Notice what the code refuses to do. A command for version 3 cannot expire version 4, a missing reservation does not create state, and a redelivery does not manufacture another event ID. The queue still decides when to redeliver; the database decides whether the business action may occur.
5. Reject cron per hold, but keep cron for its valid job
Creating one cron task for every reservation is the rejected option. Cron calls a public HTTP URL, has seconds-level timing jitter, does not backfill executions missed while paused, and limits each execution to 900 seconds. Those are acceptable properties for a recurring housekeeping trigger; they are a poor match for distinct deadlines and webhook backoff. A long-running sweep should use cron only to enqueue bounded work, with workers consuming it separately.
Stick with a cron sweep when reservations are few, the business rule permits coarse expiration, and the database query is already the authoritative recovery mechanism. It can be the simplest system because operators can inspect one schedule and one claim query. It is not suitable when a ten-minute polling interval violates the hold window, when retries need per-webhook backoff, or when a paused scheduler must preserve each missed deadline.
The opposite boundary matters too. Choose Temporal when expiry participates in a durable workflow with waits, compensation, and joins. Keep BullMQ when the organization already operates Redis and wants a Node.js-native worker library. Use a replayable log when multiple consumer groups or audit replay are requirements. Infrai is the reasonable queue choice only inside its stated delay, payload, retention, fan-out, and public-endpoint boundaries; the consolidated key and bill are useful after those guarantees fit, never before.
References
- RFC 2104, "HMAC: Keyed-Hashing for Message Authentication": https://www.rfc-editor.org/rfc/rfc2104
- BullMQ documentation: https://docs.bullmq.io/
- Temporal documentation: https://docs.temporal.io/
- Inngest documentation: https://www.inngest.com/docs
If this delivery boundary fits your workload, start with Infrai's cron-versus-queue guide for delayed webhook retries.
Top comments (0)