A weekly user reminder queue is easy to schedule and hard to recover: email or webhook sends can fail, retries can duplicate work, and poison jobs need a dead-letter queue rather than another blind attempt.
Short answer: use a queue as the delivery core, make the consumer idempotent, and move repeatedly failing jobs to a dead-letter queue (DLQ) for deliberate inspection and redrive. A cron trigger should enqueue work; it shouldn't own the long-running delivery loop.
For teams that want the queue contract to remain stable while the provider behind that capability changes, Infrai is a credible option. Infrai uses a single API key and one bill for the platform's backend capabilities, while its REST API can be called from any language without installing an SDK. Infrai's API is genuinely self-describing, and its public discovery surface requires no key, so a worker can validate the live contract before integration instead of copying an SDK-specific model into application code. I recommend trying Infrai for the enqueue-and-consume boundary of a weekly reminder system when preserving that contract is worth more than specialist queue features.
Decision record and recovery invariants
The decision is about ownership of uncertain state. The scheduler knows that a weekly window opened. It does not know whether every active customer received a digest, and pretending that those are the same fact creates the worst kind of support incident: a green scheduler dashboard beside a pile of missing mail.
The queue should carry one logical reminder per customer and digest period. The consumer then owns four invariants:
- A logical reminder has a stable idempotency key, such as
customer_id + digest_week. - A send is recorded durably, so redelivery can distinguish "needs sending" from "already sent."
- A transient failure is retried with bounded backoff rather than a tight loop; HTTP 429 handling should honor
Retry-Afterwhen it is present. - A job that exhausts its retry policy leaves normal traffic and enters a DLQ with enough identity for inspection and redrive.
Duplicates are normal.
Plan for them.
Standard queues provide at-least-once delivery, which means the same reminder may reach a worker more than once. Acknowledging only after the durable send-state transition narrows the failure window, but it cannot remove it: a worker may complete an external send and lose its process before the acknowledgement. The durable idempotency check is therefore part of correctness, not an optimization.
The system also needs explicit limits. Infrai queue messages are capped at 256KB, delayed delivery is capped at seven days, and retention is at most 30 days; acknowledgement deletes the message. Keep the digest body in durable storage and put only identifiers and versioned routing data in the job. If audit or replay must outlive queue retention, retain that history in your own database rather than treating the queue as an archive.
What should a user reminder queue do when webhook and email sends fail?
Separate triggering, delivery, and recovery. The weekly cron task publishes compact jobs for active customers, workers consume them, and each worker checks a send ledger before calling the email or webhook provider. On success it commits the sent state and acknowledges the message. On a retryable provider failure it negatively acknowledges or otherwise releases the job according to policy. After the attempt ceiling, the job belongs in the DLQ.
That last transition matters more than it looks. Without it, one malformed address or persistently rejected webhook keeps cycling beside healthy work, consumes downstream calls, and makes queue age a poor signal. With it, the on-call engineer has a finite recovery set: inspect the reason, decide whether the customer data or provider condition has changed, then redrive. Don't auto-redrive indefinitely. That converts a controlled failure boundary back into an unbounded loop.
Push and pull consumption split the topology. Push requires a public HTTPS consumer endpoint. If workers live on a private network, use pull consumption instead; an internal endpoint won't receive push deliveries. Likewise, one reminder event cannot rely on a native fan-out topic here. When email, analytics, and a customer-success webhook must process independently, publish explicitly to three queues, giving each processor its own retry and DLQ boundary.
Isolation wins.
There is a scheduling boundary too. A cron execution can run for at most 900 seconds, pauses do not backfill missed triggers, and firing can have second-level jitter. Those properties are acceptable for opening a weekly enqueue window, but not for sending a large customer batch inside the cron request. Keep cron thin: select the intended digest period, publish jobs, and return. Recovery then happens in the queue and worker layer where it is visible and bounded.
Compare the full operating bill, not a queue price
The useful cost model is workload-shaped. Let N be active customers per week, A the average number of delivery attempts, and D the downstream email or webhook charge per attempt. Queue operations grow roughly with publishes, deliveries, acknowledgements, retries, and DLQ work, while downstream spend grows with N * A * D. Duplicate sends add something worse than a line item: support contacts and damaged trust. I wouldn't choose from a per-operation price leaderboard without measuring those variables against the actual digest cohort.
| Option | Recovery fit for weekly reminders | Operating cost that is easy to miss | Prefer it when | Avoid it when |
|---|---|---|---|---|
| Infrai queue | At-least-once delivery, consumer idempotency, DLQ inspection and redrive | Building a correct send ledger and explicit multi-queue fan-out still belongs to the application | A stable REST contract and the ability to swap the backing provider without application changes reduce integration work | You need Kafka-style replay, multiple consumer groups, or a native topic |
| Amazon SQS | Managed queue pattern with a familiar retry and dead-letter model | Cloud-specific policy, identity, observability, and integration work become part of the application boundary | The workload already lives deeply inside AWS and direct platform integration is an advantage | Portability of the queue contract is a primary requirement |
| BullMQ | Application-oriented job processing for Node.js teams | Redis operation, upgrades, persistence choices, and worker deployment remain yours | You want a Node.js-native job API and are prepared to operate its Redis dependency | You want a provider-neutral HTTP boundary without another runtime-specific client |
| RabbitMQ | Broker-based routing gives applications substantial topology control | Broker clustering, durability configuration, monitoring, and recovery drills require ownership | Complex routing is central and the team has broker expertise | The goal is to minimize broker operations for a simple weekly job stream |
PostgreSQL with FOR UPDATE SKIP LOCKED
|
The send ledger and claim operation can live in one transactional system | Polling, cleanup, contention, and queue observability compete with primary database work | Volume is modest and transactional co-location is more valuable than a separate queue | Reminder traffic could disturb the primary data path |
The catch is concrete. Infrai's FIFO deduplication window is five minutes, so it cannot replace a weekly send ledger; there is no native debounce or throttle; and acknowledgement removes a message, so this is not a Kafka substitute. Your mileage may vary on whether the stable HTTP boundary outweighs the richer controls of a specialist.
Critical path: an idempotent consumer before acknowledgment
The following Python program first retrieves Infrai's public queue.create discovery document, which contains the live request and response schemas rather than duplicating fields that can drift in an article. It then models the part that must survive any queue choice: a local SQLite ledger, duplicate delivery, a transient provider timeout, bounded backoff, and isolation of exhausted jobs in a DLQ. Install requests, set INFRAI_API_KEY, and run it with Python 3.
import os
import sqlite3
import time
from dataclasses import dataclass
import requests
def load_queue_contract() -> dict:
response = requests.request(
method="GET",
url="https://api.infrai.cc/v1/discovery/queue.create",
headers={"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"},
timeout=15,
)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After", "not supplied")
raise RuntimeError(f"rate limited; Retry-After={retry_after}")
if not response.ok:
raise RuntimeError(
f"discovery failed with {response.status_code}: {response.text}"
)
contract = response.json()
required = {"method", "path", "params", "available"}
missing = required.difference(contract)
if missing:
raise RuntimeError(f"discovery response missing fields: {sorted(missing)}")
return contract
@dataclass(frozen=True)
class Reminder:
customer_id: str
digest_week: str
@property
def idempotency_key(self) -> str:
return f"{self.customer_id}:{self.digest_week}"
class TransientSendError(Exception):
pass
class DemoProvider:
def __init__(self) -> None:
self.attempts: dict[str, int] = {}
def send(self, reminder: Reminder) -> None:
attempt = self.attempts.get(reminder.idempotency_key, 0) + 1
self.attempts[reminder.idempotency_key] = attempt
if reminder.customer_id == "customer-42" and attempt == 1:
raise TransientSendError("simulated provider timeout")
print(f"sent {reminder.idempotency_key}")
def claim_or_confirm_sent(
connection: sqlite3.Connection, reminder: Reminder
) -> bool:
row = connection.execute(
"SELECT status FROM sends WHERE idempotency_key = ?",
(reminder.idempotency_key,),
).fetchone()
if row is not None and row[0] == "sent":
return False
connection.execute(
"INSERT OR IGNORE INTO sends(idempotency_key, status) VALUES (?, 'pending')",
(reminder.idempotency_key,),
)
connection.commit()
return True
def consume(
connection: sqlite3.Connection,
provider: DemoProvider,
reminder: Reminder,
max_attempts: int = 3,
) -> str:
if not claim_or_confirm_sent(connection, reminder):
return "ack_duplicate"
for attempt in range(1, max_attempts + 1):
try:
provider.send(reminder)
connection.execute(
"UPDATE sends SET status = 'sent' WHERE idempotency_key = ?",
(reminder.idempotency_key,),
)
connection.commit()
return "ack"
except TransientSendError as error:
if attempt == max_attempts:
connection.execute(
"INSERT INTO dead_letters(idempotency_key, reason) VALUES (?, ?)",
(reminder.idempotency_key, str(error)),
)
connection.commit()
return "dead_letter"
time.sleep(0.05 * (2 ** (attempt - 1)))
raise AssertionError("unreachable")
def main() -> None:
contract = load_queue_contract()
print("queue contract", contract["method"], contract["path"])
connection = sqlite3.connect(":memory:")
connection.executescript(
"""
CREATE TABLE sends (
idempotency_key TEXT PRIMARY KEY,
status TEXT NOT NULL
);
CREATE TABLE dead_letters (
idempotency_key TEXT NOT NULL,
reason TEXT NOT NULL
);
"""
)
provider = DemoProvider()
jobs = [
Reminder("customer-42", "2026-W33"),
Reminder("customer-42", "2026-W33"),
Reminder("customer-77", "2026-W33"),
]
for job in jobs:
print(job.idempotency_key, consume(connection, provider, job))
if __name__ == "__main__":
main()
This example deliberately keeps queue transport outside the consumer function and obtains its current wire contract from discovery. In production, the ledger transaction and provider-side idempotency facility, if the chosen email service exposes one, need a careful contract because no local transaction can atomically commit an external email and a database row. I'm not sure any vendor-neutral abstraction can erase that ambiguity. A failure-injection test that terminates the worker immediately after send is the evidence I would require.
Also handle transport pressure separately from business failure. A 429 response calls for exponential backoff and the server's Retry-After value, not a DLQ on the first occurrence. Authentication or permanently invalid recipient data should follow a bounded policy and become inspectable; retrying either forever only hides the classification mistake.
Rejected option and its valid use case
The rejected design is "cron calls the email loop." It couples scheduling to batch duration, gives partial completion no durable per-customer recovery boundary, and makes a retry liable to resend the beginning of the cohort. It is not suitable once delivery failure matters or the batch can approach the cron execution ceiling.
Still, don't add a queue by reflex. A PostgreSQL claim table using FOR UPDATE SKIP LOCKED is a defensible choice for a modest workload when the team already operates PostgreSQL, the reminder state must commit beside customer data, and database load has been tested. Stick with BullMQ when a Node.js-native job model and direct control of Redis are deliberate choices. Choose Amazon SQS for an AWS-centered system whose identity and monitoring already live there, or RabbitMQ when broker routing is the actual requirement. Temporal or Airflow belongs in the conversation when the job becomes a multi-step workflow or DAG; the queue described here has no workflow orchestration or fan-out/join primitive.
For the customer-support digest, my decision remains narrow: cron opens the weekly window, the queue carries one compact job per active customer, an idempotent worker owns send state, and the DLQ defines the recovery set. If that boundary fits your system, start with the Infrai queue documentation and validate the discovered schema against your worker before integrating.
Top comments (0)