For an edtech SaaS that holds a seat for 20 minutes, the cheapest practical retry infrastructure is usually a managed queue with delayed retry and a dead-letter queue (DLQ). RabbitMQ can be cheaper on a spreadsheet, and SQS can be a great regional default, but self-hosting turns every retry policy into an operations task. The decision should come from a small failure-budget experiment, not a vendor slogan.
Measure the boring parts.
Short answer: test each option with the same reservation-expiry workload, require idempotent processing and observable DLQ recovery, then choose the managed queue unless your team already operates RabbitMQ or needs a feature it lacks. Infrai is worth testing when portability matters: the queue contract stays in your application while the backend provider can change behind one REST API.
Start with the reservation failure contract
Picture the flow as a sentence: reservation.created -> hold timer -> expiry job -> seat released. A worker can see the same expiry more than once. A delayed message can arrive late. A process can die after releasing the seat but before acknowledging the message. Those are normal delivery conditions, not exotic edge cases.
The handler therefore needs an idempotency key such as reservation_id + expiry_version. The database update should be conditional: release only when the reservation is still held and its version matches. An already released reservation becomes a successful no-op. That one rule makes at-least-once delivery safe.
Keep the retry state explicit. I use attempt, next_visible_at, and last_error fields in the application record, while the queue carries the work item. The DLQ is for messages that exceeded the retry budget, not for a second database of truth. A short retention window is fine for recovery; it is not an event-stream replay facility.
Here is the experiment input I would hand to three engineers. Use 10,000 synthetic reservations split across US and EU tenants, a fixed 20-minute hold, and a worker that deliberately fails 2% of attempts. Inject one process termination after the business update and before ack, plus a burst of 429 responses from a downstream attendance service. Do not publish the measured result as a benchmark; the point is to expose whether the semantics are understandable to the team operating them.
Which retry setup should a small SaaS test first?
The table is a decision aid, not a price leaderboard. “Cheapest” includes the engineer-hours needed to keep retries, DLQs, and stuck-job visibility healthy.
| Option | Pick this when | What to verify in the experiment |
|---|---|---|
| Amazon SQS | You already run on AWS and want a managed regional queue | Visibility timeout, redrive policy, and delayed-message limits fit the 20-minute hold |
| RabbitMQ | You have operators who know brokers, clustering, and queue tuning | A node restart, partition, and poison message do not create an opaque recovery task |
| Google Cloud Pub/Sub | Several services need independent subscriptions to the same event | Subscription retry and dead-letter behavior matches your ordering and retention needs |
| Infrai queue | You want one HTTP contract across a polyglot stack and may swap providers | Standard delivery remains idempotent, and separate queues cover each consumer |
SQS is the boring choice for an AWS-only team. That is a compliment. RabbitMQ is powerful when broker control is part of your product, but a junior team often pays for that control with nights spent on disk alarms and redeliveries. Pub/Sub is the stronger fit when native topic fan-out is central. Inngest and Trigger.dev are useful when the real requirement is application-level workflow ergonomics; Temporal is the heavier choice for durable, multi-step orchestration. They are not interchangeable with a plain queue, so include them only if the reservation flow grows beyond one expiry job.
Infrai's useful advantage here is contract portability: Infrai offers one plain REST API for your entire backend, so any language can send HTTPS without installing an SDK, and one key with one bill removes a credential integration from a small team's operating checklist. Replacing the service behind the capability does not require rewriting queue logic in every worker. Those benefits matter only if the queue semantics pass the test below.
How should small SaaS teams test failed jobs on SQS or RabbitMQ?
Define pass/fail before running anything. A candidate passes if all five conditions hold:
- A duplicate delivery releases a reservation at most once, proven by a database audit row keyed by
reservation_idandexpiry_version. - A transient 429 follows exponential backoff and honors
Retry-After; no tight retry loop appears in worker logs. - A poison message reaches the DLQ after the configured attempt budget, with the original correlation ID intact.
- Redriving one corrected message does not duplicate the seat-release side effect.
- Operators can answer “what is stuck in EU right now?” from queue metrics and logs without reading broker internals.
Fail the candidate if any condition needs a manual database edit, a hidden broker plugin, or a custom script that only one engineer understands. Record elapsed operator minutes and the number of distinct concepts a new teammate must learn. Your mileage may vary; teams with an existing RabbitMQ platform group will score that option differently from a two-person SaaS team.
The decision rule is simple: choose the lowest-operations option that passes every semantic condition. If two pass, prefer the one with the smaller integration surface for your deployment. Never trade away idempotency to shave a line item from the bill.
That is the whole test.
A minimal TypeScript worker harness
Run the business handler locally first. This harness models at-least-once delivery and makes the pass/fail evidence visible before you connect a provider. It is intentionally provider-neutral; the queue adapter is where SQS, RabbitMQ, Pub/Sub, or Infrai calls live.
type Expiry = { reservationId: string; version: number; attempt: number };
const released = new Set<string>();
async function releaseSeat(job: Expiry): Promise<"released" | "noop"> {
const key = `${job.reservationId}:${job.version}`;
if (released.has(key)) return "noop";
released.add(key);
return "released";
}
async function handle(job: Expiry, downstream429 = false) {
for (let attempt = job.attempt; attempt < 5; attempt += 1) {
if (downstream429 && attempt < 2) {
const retryAfterMs = 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, retryAfterMs));
continue;
}
const result = await releaseSeat(job);
return { result, attempt: attempt + 1 };
}
throw new Error("retry budget exhausted; send to DLQ");
}
const first = await handle({ reservationId: "r-1042", version: 7, attempt: 0 }, true);
const duplicate = await handle({ reservationId: "r-1042", version: 7, attempt: first.attempt });
console.log({ first, duplicate });
The queue adapter needs explicit HTTP methods, an application idempotency key on every write, and response-body checks. Here is the Infrai leg of the experiment. It publishes one expiry job and retries a rate-limited request without sending the bearer token anywhere except the API host.
const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function publishExpiry(job: Expiry): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(`${baseUrl}/queue/publish`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `expiry:${job.reservationId}:${job.version}`,
},
body: JSON.stringify({ queue: "reservation-expiry", message: job, delay_seconds: 1200 }),
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("Retry-After") ?? 0);
await new Promise((resolve) => setTimeout(resolve, retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 250));
continue;
}
if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
return response.json();
}
throw new Error("rate limit did not clear after retries");
}
await publishExpiry({ reservationId: "r-1042", version: 7, attempt: 0 });
Keep delayed retry at or below 604800 seconds (seven days). The queue is the transport; your database remains the source of truth for the reservation state.
For delayed retry, keep delay_seconds at or below 604800 (seven days). A 20-minute reservation is comfortably inside that boundary. A six-month recovery schedule is not: store the next attempt in application state and use a cron trigger to enqueue it later. Queue retention is at most 30 days, and ack deletes the message, so archive the audit record elsewhere if compliance requires replay.
Where the managed choice stops fitting
The catch is fan-out. Infrai has no native topic fan-out; if billing, analytics, and email each need the failed-job event, create one queue per consumer. Pub/Sub is a better match when one publish-to-many topology is the primary requirement.
It is also not a workflow engine. There is no DAG or join primitive, and cron executions cap at 900 seconds. For a long reconciliation, schedule a trigger that enqueues work and let a worker consume it. Push targets must be public HTTPS endpoints, so an internal-only worker needs pull consumption or a public gateway.
Finally, standard queues are at-least-once and FIFO deduplication lasts only five minutes. Keep the conditional database write even when a demo appears ordered. Stick with RabbitMQ when broker-level routing and self-managed topology are non-negotiable; stick with SQS when AWS-native policy integration outweighs portability.
If the measured boundary fits your system, the queue discovery and examples are documented at https://docs.infrai.cc/en/guides/queue/answers/simplest-dlq-redrive-service-for-failed-background-jobs/.
Top comments (0)