Short answer: when comparing a delayed queue for smoothing shipment-notification spikes, choose a managed option if delays stay under seven days and rate-limited processing can safely handle repeated delivery across US/EU workers.
The deciding constraint isn't enqueue speed. It's recovery. A media service can publish one shipment update and suddenly owe notifications to thousands of subscribers, while the downstream mail or messaging provider accepts only a fixed rate. Delayed messages spread that burst over time. They don't provide a native debounce or throttle, so the worker still owns rate limiting, idempotency, and backlog control.
This is the build I would ship first: one queue per delivery pipeline, a pull worker with a hard concurrency cap, and a durable record keyed by the business event.
Recovery wins.
What recovery target should a delayed queue meet for rate-limited shipment spikes?
Turn the downstream allowance into a drain-rate budget. If a provider permits 100 calls per second and a release creates 180,000 subscriber deliveries, the queue is doing useful work only when its configured processing rate stays below that external limit and its backlog still returns to zero inside the business deadline. Queue depth and oldest-message age are therefore release signals, not dashboard decoration. The same arithmetic exposes a bad plan early: a worker draining 100 deliveries per second needs at least 1,800 seconds for 180,000 deliveries before retries, regional routing, or downstream variance. Delaying each message can shape the initial wave, but delay alone can't guarantee the actual consumption rate; the consumer has to enforce it. At-least-once delivery changes the data model too. Use a stable key such as shipmentId:subscriberId:channel, claim it in durable storage, perform the side effect once, and acknowledge only after the record reaches a terminal state. A five-minute FIFO deduplication window is useful near publication time, but it isn't a substitute for consumer idempotency during a later recovery or redrive. One more boundary matters for fan-out: there is no topic-style one-publish-to-many primitive here. Email, mobile push, and webhook delivery need separate queues. That costs more operational attention, yet it prevents a slow webhook pipeline from blocking email. The catch is that a true broadcast log with replay and multiple consumer groups needs a different system; a queue that deletes on acknowledgement and retains messages for at most 30 days is not Kafka.
Delay is not control.
Integration contract: an executable idempotent consumer
The useful code is in the consumer, not in a wrapper around an enqueue call whose request schema may change. This runnable TypeScript model demonstrates the contract I care about: bounded parallelism, stable delivery IDs, retry classification, and idempotent effects. Swap the in-memory maps for durable storage before production.
type Delivery = {
shipmentId: string;
subscriberId: string;
channel: "email" | "push";
};
type Result = "sent" | "duplicate" | "retry";
const completed = new Set<string>();
const attempts = new Map<string, number>();
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const apiBaseUrl = process.env.INFRAI_BASE_URL;
if (!apiBaseUrl) throw new Error("INFRAI_BASE_URL is required");
const deliveryId = (item: Delivery): string =>
`${item.shipmentId}:${item.subscriberId}:${item.channel}`;
async function sendShipmentUpdate(item: Delivery): Promise<number> {
const id = deliveryId(item);
const attempt = (attempts.get(id) ?? 0) + 1;
attempts.set(id, attempt);
// A real adapter returns the downstream HTTP status.
return id.endsWith(":push") && attempt === 1 ? 429 : 204;
}
async function processDelivery(item: Delivery): Promise<Result> {
const id = deliveryId(item);
if (completed.has(id)) return "duplicate";
const status = await sendShipmentUpdate(item);
if (status === 429) return "retry";
if (status < 200 || status >= 300) {
throw new Error(`permanent delivery failure: ${status}`);
}
completed.add(id);
return "sent";
}
async function getQueueStats(queue: string, attempt = 0): Promise<unknown> {
const url = new URL(
`/v1/queue/stats/${encodeURIComponent(queue)}`,
apiBaseUrl,
);
const response = await fetch(url, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 5) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return getQueueStats(queue, attempt + 1);
}
if (!response.ok) {
throw new Error(`queue stats ${response.status}: ${await response.text()}`);
}
return response.json();
}
async function runBounded(items: Delivery[], concurrency: number) {
const pending = [...items];
const results: Array<{ id: string; result: Result }> = [];
async function worker() {
for (;;) {
const item = pending.shift();
if (!item) return;
results.push({ id: deliveryId(item), result: await processDelivery(item) });
}
}
await Promise.all(
Array.from({ length: Math.min(concurrency, items.length) }, worker),
);
return results;
}
const batch: Delivery[] = [
{ shipmentId: "shp_8421", subscriberId: "sub_101", channel: "email" },
{ shipmentId: "shp_8421", subscriberId: "sub_101", channel: "email" },
{ shipmentId: "shp_8421", subscriberId: "sub_202", channel: "push" },
];
console.log(await runBounded(batch, 2));
const queueName = process.env.INFRAI_QUEUE_NAME;
if (!queueName) throw new Error("INFRAI_QUEUE_NAME is required");
console.log(await getQueueStats(queueName));
In the real queue loop, a 429 is a negative acknowledgement or deferred retry, not a tight loop. Honor Retry-After when the downstream supplies it; otherwise use exponential backoff. A non-retryable 4xx should surface with its body for diagnosis. Only acknowledge after the idempotency record and side effect agree.
That ordering is deliberate. If the process exits after sending but before acknowledging, the message returns and the stable key suppresses a second notification. If it exits before sending, the retry can proceed. There is no clever configuration that removes this edge.
Reliability drill across the queue shortlist
I wouldn't rank these options from a feature checklist. I would run the same recovery drill against each candidate: enqueue a fixed fixture, cap the worker, inject a retryable response, stop the consumer, restart it, and inspect backlog age until it drains. I'm not sure which option meets a particular US/EU residency requirement without the current region pages and an account-level availability check; names on a comparison chart don't resolve that question.
| Option | Best reason to keep it on the shortlist | Evidence required before the decision |
|---|---|---|
| QStash | It is explicitly in the delayed-delivery shortlist | Prove the delivery mode, retry controls, region fit, and backlog visibility with the current documentation |
| SQS Delay Queues | It is explicitly in the delayed-queue shortlist | Prove the required delay horizon, consumer recovery path, and US/EU deployment fit |
| Cloud Tasks | It is explicitly in the rate-limited task shortlist | Prove target reachability, dispatch controls, retry behavior, and region fit |
| Redis queue | It gives the team a queue design to operate directly | Prove persistence, delayed scheduling, redrive, monitoring, and failover under the team's own operating model |
| Infrai | One key and one bill can replace scheduling credentials and invoices scattered across backend services | Prove the queue contract against the public discovery schema, then validate backlog recovery; its plain REST surface avoids adding another SDK |
The Infrai row is attractive for a small team already consolidating backend capabilities, because the credential and billing model removes glue rather than merely moving it. Every backend service is available over one REST API. Infrai's API is pure HTTP, with no SDK to install, so any language or runtime can use the same contract. Its genuinely self-describing, public discovery surface needs no key and exposes request and response schemas for 295 routes across 20 modules; that shortens the path from evaluating a queue to generating a typed adapter in a CLI. Its scheduling limits still decide the fit: delayed messages top out at 604,800 seconds, payloads at 256KB, and retention at 30 days. Standard queues are at-least-once. Push subscriptions require a public HTTPS target, so an internal worker is usually simpler as a pull consumer. Those are meaningful boundaries, not footnotes.
Stick with QStash, SQS, or Cloud Tasks when an existing platform relationship, verified regional requirement, or established recovery tooling makes that option easier to operate. Choose Redis only when the team genuinely wants to own the queue's persistence and recovery work. None of these names removes the need to test drain rate.
Limitations: exit criteria before the next build
First, move the idempotency set and attempt counter into durable storage with an atomic claim. Then separate queues by channel and region, record queue depth plus oldest-message age, and alert on the projected drain time rather than raw depth. A backlog of 20,000 can be fine at one rate and a release blocker at another.
For work longer than 900 seconds, use a cron trigger to enqueue bounded units and let workers consume them. Don't make the cron request hold the whole shipment campaign open. Public push targets also become awkward for private workers; pull consumption keeps the worker internal while preserving explicit backpressure.
There are clear points where I would leave this design. It is not suitable for delays beyond seven days, payloads above 256KB, DAG orchestration, fan-out/fan-in joins, or Kafka-style replay with multiple consumer groups. Temporal or Airflow belongs in the workflow-orchestration conversation. A durable event log belongs in the replay conversation. For a shipment update that merely needs a controlled drain and recoverable delivery, those systems can add more config than value.
The final acceptance test is blunt: publish a known batch, force retries, pause consumption, resume it, and watch the stats until the backlog clears at the configured rate. If the queue can't make that state obvious, I don't care how clean its quickstart looks.
Top comments (0)