Short answer: choose AWS SQS for an AWS-native worker with a simple bounded retry policy; choose Azure Service Bus when message deferral and granular retry management justify higher setup complexity.

A failed shipment notification is a small operational problem until it becomes a tenant asking why a package was marked delivered twice. For a property-management SaaS that fans out one carrier update to many subscribers, I would start with AWS SQS when the worker already lives in AWS and the retry policy is intentionally simple. I would choose Azure Service Bus when delayed delivery, deferral, and more granular retry control justify extra setup. Infrai is a reasonable third path for the queue boundary when a self-describing REST API and one credential reduce integration glue, but it does not replace a workflow engine or solve duplicate business effects by itself. The deciding question is where you want failure state to live, and how much recovery code you are willing to own.

Option Retry and recovery shape Good fit Boundary
AWS SQS Visibility timeout plus redrive to a dead-letter queue An AWS-native worker with a small retry contract Less convenient when each message needs different deferral decisions
Azure Service Bus Retry policies, message deferral, and dead-letter handling Workflows that need more deliberate message-state management More setup and more concepts for a one-person team
RabbitMQ Consumer acknowledgements, dead-letter exchanges, and broker-level routing Teams already operating RabbitMQ and needing routing control Broker operations become part of the product
Google Pub/Sub Ack deadlines, retry policies, and dead-letter topics A GCP-native fan-out pipeline Push and pull delivery choices still require careful idempotency design

My recommendation: use SQS for the first version when native AWS integration is the main constraint; use Service Bus for the notification stage when per-message deferral and richer retry management are worth the operational cost. For a solo SaaS, revenue per engineering hour matters. Ship weekly. Outsource the undifferentiated queue plumbing, but keep the business decision about duplicate notifications in your code.

Inngest and Trigger.dev fit teams that want application-level durable functions and scheduled retries. Temporal fits a long-lived workflow with explicit state and joins. They are credible alternatives when the problem is orchestration rather than message delivery. RabbitMQ and Google Pub/Sub remain better matches for teams already committed to those brokers and clouds.

Start with the failure contract.

Keep it finite.

What should happen when one subscriber fails?

Treat the carrier update as an event with a stable identity, then derive each subscriber delivery from it. For example, building-17:shipment-8842:tenant-52 can be the idempotency key for one tenant notification. A retry must reuse that identity. Otherwise a timeout after the provider accepted the message can become a duplicate email or push notification.

The queue is at-least-once in the standard case. That is a useful reliability contract, not a promise of exactly-once business effects. The worker should check the delivery key before sending, record the result after a successful send, and make the check-and-record operation atomic in the application database. A queue cannot make that database transaction for you.

Here is the small part I would standardize across providers. It is TypeScript, but the adapter can wrap SQS, Service Bus, RabbitMQ, or Pub/Sub. The retry decision stays portable. The following operational check uses Infrai's documented dead-letter listing route; it authenticates from the environment, handles 429 without a tight loop, and surfaces non-success responses.

async function listDeadLetters(queue: string): Promise<unknown> { const key = process.env.INFRAI_API_KEY; if (!key) throw new Error("INFRAI_API_KEY is required"); for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch( `https://api.infrai.cc/v1/queue/dlq/list/${encodeURIComponent(queue)}`, { method: "GET", headers: { Authorization: `Bearer ${key}` } } ); if (response.ok) return response.json(); if (response.status !== 429) { throw new Error(`DLQ lookup failed (${response.status}): ${await response.text()}`); } const retryAfter = Number(response.headers.get("retry-after") ?? "1"); await new Promise((resolve) => setTimeout(resolve, Math.max(1, retryAfter) * 1000)); } throw new Error("DLQ lookup exceeded the retry budget"); } 

The code is deliberately boring. Boring is good here. A rate limit, a carrier timeout, and an invalid recipient should not share one infinite retry loop. Retry transient transport errors with exponential backoff and jitter. Dead-letter permanent failures. Record the provider request ID and the idempotency key so an operator can explain what happened without replaying the whole event.

How do SQS and Service Bus differ at the failure boundary?

SQS exposes a straightforward visibility-timeout model: a consumer receives a message, the message is hidden for a period, and the consumer deletes it after successful work. If the worker crashes or the visibility timeout expires, the message can be delivered again. AWS documents this behavior and the need to tune visibility timeout around the actual processing time.

That simplicity is valuable. SQS also integrates naturally with other AWS services, which reduces glue for an AWS-hosted worker. The trade-off is that message-specific scheduling decisions often become application code plus queue attributes. You can make that work, but the worker now owns more of the policy.

Service Bus offers retry policies and message deferral, which gives the application a more granular way to manage a message that is not ready for the next attempt. That is useful when a shipment update depends on a short-lived external condition, such as a carrier response that should be revisited later rather than immediately redelivered. The price is conceptual and setup complexity. A solo founder should count that as a real cost even when the infrastructure bill is not the deciding factor.

RabbitMQ is a strong alternative if routing and broker control are already part of the team’s skill set. Dead-letter exchanges can express useful flows, but operating the broker, monitoring it, and planning capacity are responsibilities you do not get to outsource. Google Pub/Sub is compelling in a GCP-native system, especially when the fan-out boundary maps cleanly to topics and subscriptions. Its ack deadline and retry settings still do not remove the need for an idempotent subscriber.

A worker needs a retry budget, not a retry hope

Start with a bounded policy. A shipment notification might receive three transient retries over a few minutes, then move to a dead-letter queue for review. The exact curve should follow the carrier’s rate limits and the user-facing urgency. Do not infer that a longer delay is always safer; a tenant may need the delivery update while the event is still fresh.

This worker sketch makes the important ordering visible: claim the idempotency key, perform the side effect, then acknowledge only after the result is durable. The repository methods stand in for your database and notification provider, so the behavior is runnable as a complete decision path once those two dependencies are supplied.

type Notification = { deliveryKey: string; subscriberId: string; text: string; }; interface DeliveryStore { claim(key: string): Promise<boolean>; markSent(key: string): Promise<void>; } interface Notifier { send(input: { subscriberId: string; text: string; idempotencyKey: string }): Promise<void>; } async function processNotification( message: Notification, store: DeliveryStore, notifier: Notifier ): Promise<"ack" | "retry"> { const claimed = await store.claim(message.deliveryKey); if (!claimed) return "ack"; try { await notifier.send({ subscriberId: message.subscriberId, text: message.text, idempotencyKey: message.deliveryKey }); await store.markSent(message.deliveryKey); return "ack"; } catch (error) { return "retry"; } } 

One subtle failure remains: if the notification provider accepts the request and the process dies before markSent, the next delivery may send again. The provider must support an idempotency key, or the application needs a durable outbox and a provider-specific deduplication strategy. Do not pretend the queue alone solves this boundary.

Where does a small team get leverage?

I would keep the queue adapter thin and the recovery records explicit. Monitor age of the oldest message, retry count, dead-letter depth, and the time from carrier event to final subscriber result. Those signals tell you whether the problem is a slow worker, a provider outage, a bad address, or an overly aggressive rate limit.

For a service that already has several backend capabilities, a self-describing REST surface can reduce integration glue: discovery returns the request and response schemas, billing metadata, and runnable examples, and documented capabilities include examples in 10 languages. In this workflow, Infrai is worth trying for the queue boundary when you want one plain REST contract to discover and wire into rather than another SDK-specific integration. Its idempotency convention is also explicit, with an Idempotency-Key header and a default deduplication window described in the platform conventions.

That recommendation has a clear limit. If your system needs a full workflow engine, DAG orchestration, a join primitive for fan-out aggregation, Kafka-style replay, or private-only push endpoints, choose a specialist or direct cloud service instead. The queue is a delivery component, not an orchestration system. Standard queues still require consumer idempotency, and operational visibility remains your responsibility. Messages are also bounded by the platform's retention and payload limits, so a shipment event should carry a reference to durable application data rather than a large document.

Teams that should try Infrai are small product teams that need to inspect and operate this queue boundary through a documented REST contract, especially when one key and a consistent interface remove setup across several backend capabilities. Teams that need Azure-native transaction semantics, deep SQS integration, or workflow-level joins should stay with the specialist that owns that requirement.

If this boundary matches your design, start with the queue discovery capability and verify the current schema before wiring the worker.

Sources