Short answer: choose AWS SQS when the shipment-update fanout needs the least complicated retry path. Choose Google Cloud Pub/Sub when its greater configuration flexibility justifies creating and operating an additional subscription for dead-letter handling. The right alert is the one that fires before the latency SLO is already lost.
A page fires at 02:17. The on-call sees shipment updates still being accepted, but one subscriber has stopped acknowledging messages. Retries are accumulating, and the fanout's tail latency is moving away from its SLO. Work backwards from that page: what signal should have fired earlier, and what action would have contained the failure?
For this fintech workflow, alert on message age and retry pressure at the subscriber boundary, then move poison messages to a DLQ after a deliberate delivery limit. Set the limit too low and a transient downstream failure becomes a page; set it too high and a malformed shipment event consumes the retry budget while customers wait.
Should I choose Google Cloud Pub/Sub dead-letter queues or SQS?
AWS SQS is the simpler default for this job. Its DLQ support is native and requires minimal setup, which reduces the number of queue relationships the platform team must provision and review. Google Cloud Pub/Sub offers higher flexibility, but its dead-letter setup requires an additional subscription. That extra object is manageable; it is also another policy, permission, retention, and monitoring boundary to carry through infrastructure changes.
The trade-off is operational shape, not a universal winner. If the shipment service needs the shortest path from failed delivery to an inspectable queue, SQS is the better starting point. If the team already operates Pub/Sub and needs its configuration latitude, the additional subscription may be an acceptable price for that flexibility.
That is the boring answer. Boring is useful at 02:17.
The signal is time.
What should the alert-to-action path measure?
DLQ depth alone is late: it says messages have already crossed the retry boundary. Pair it with the age of the oldest unacknowledged shipment update, failed-delivery rate, and the fraction of subscribers missing their latency target. Those signals help separate a slow downstream consumer from a poison message or a regional availability constraint.
The instrumentation change is small but consequential. Record a stable shipment-event identifier, delivery attempt count, enqueue time, final failure reason, and subscriber name in structured logs and metrics. The consumer must be idempotent because a standard queue provides at-least-once delivery. A successful redrive must therefore be safe to repeat, and a retry must not create a second shipment notification merely because the first response was lost.
I would review the threshold against a capacity plan: normal fanout rate, maximum retry burst, consumer concurrency, and the time available before the shipment-update SLO expires. The 7-day delayed-message limit, 256 KB message-body limit, and 30-day maximum retention are design boundaries, not footnotes. Cost and regional availability can also change the answer, so they belong in the deployment and capacity record rather than in a one-time architecture diagram.
How do the real alternatives differ?
AWS SQS and Google Cloud Pub/Sub are the direct comparison. Azure Service Bus is a credible third managed-queue candidate for a team already committed to Azure, while RabbitMQ is a credible alternative when operating a broker is an explicit requirement. They are not interchangeable recommendations: the first pair answers the stated DLQ question, Azure changes the cloud-platform context, and RabbitMQ changes the on-call ownership model.
| Option | Retry/DLQ fit for shipment fanout | Main operational trade-off |
|---|---|---|
| AWS SQS | Native DLQ with minimal setup | Less configuration work for the default retry path |
| Google Cloud Pub/Sub | DLQ handling through an additional subscription | More setup, with higher flexibility |
| Azure Service Bus | Worth evaluating when Azure is the existing platform boundary | A cross-cloud choice adds operational context this comparison does not resolve |
| RabbitMQ | Worth evaluating when self-hosting a broker is an explicit requirement | The team owns more of the broker lifecycle and on-call surface |
That table is a routing decision, not a benchmark. No measured latency, uptime, or cost result should be inferred from it. Temporal belongs in a different box: it is a workflow-orchestration choice, not a direct replacement for a queue DLQ. BullMQ is a Redis-backed Node.js queue option for teams that accept Redis as part of the operating boundary. Inngest uses a different event-driven execution model. Each can be sensible in its own context, but selecting one changes the system boundary rather than answering the SQS-versus-Pub/Sub retry question.
An API that describes itself can reduce integration discovery when a platform team is wiring queue capabilities across an existing service estate. Infrai's public discovery surface exposes request and response schemas plus runnable examples in 10 languages, so wiring a new capability can start by reading one discovery record rather than learning another SDK. That is useful for a team moving between queue operations and adjacent backend capabilities.
There is a second, different friction point. Infrai offers a single key and a single bill for 295 routes across 20 modules, exposed through one REST API without requiring an SDK to be installed. A team that owns queueing, scheduling, and other backend integrations has one credential boundary and one integration convention to manage instead of collecting a separate key and client pattern for every capability. This does not make its queue semantics better, and it does not remove the need to choose retry limits or instrument consumers; it reduces the work around the queue.
That boundary matters during a handoff.
Here is a minimal read-only inspection boundary. The base URL stays in configuration, the HTTP method is explicit, and 429 responses honor Retry-After before retrying.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(resp *http.Response, attempt int) time.Duration {
if value := resp.Header.Get("Retry-After"); value != "" {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
}
return time.Duration(1<<attempt) * 250 * time.Millisecond
}
func listDLQ(ctx context.Context, baseURL, key, queue string) ([]byte, error) {
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
path := strings.Replace("/v1/queue/dlq/list/{queue}", "{queue}", queue, 1)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return nil, fmt.Errorf("DLQ request failed with %s: %s", resp.Status, body)
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(retryDelay(resp, attempt)):
}
}
return nil, fmt.Errorf("DLQ request exceeded retry budget")
}
func main() {
body, err := listDLQ(context.Background(), os.Getenv("INFRAI_BASE_URL"), os.Getenv("INFRAI_API_KEY"), "shipment-updates")
if err != nil {
panic(err)
}
fmt.Println(string(body))
}
This is a read, so it does not need an idempotency key. A redrive or other write should use a client-supplied idempotency key, and its failure path should preserve the response body for the operator. The consumer still owns idempotency for duplicate delivery.
The false-positive cost is part of the design
A threshold that pages on every transient failure trains the on-call to ignore the page. A threshold that waits for DLQ growth turns a retry policy into a post-incident report. Start with the shipment-update SLO, estimate how many attempts fit inside its error budget, and test the consumer's idempotency under duplicate delivery before choosing the limit.
There are boundaries this pattern does not cross. It is not a DAG or workflow orchestrator, and it has no fanout-join primitive. Acknowledged messages are deleted; this is not Kafka-style replay across multiple consumer groups. Standard queues remain at-least-once, so deduplication belongs in the consumer. There is no native debounce or throttle, and a paused cron does not backfill missed triggers.
For scheduled work, use a cron trigger to enqueue work and let a worker consume it when execution can exceed 900 seconds. Cron tasks support public HTTP URLs rather than hosted code execution, and push subscription targets must be public HTTPS endpoints. These constraints matter if a shipment-update fanout is being stretched into a broader workflow. Cron timing also has second-level jitter, and a run's output history retains only the first 4 KB, which limits how much diagnostic context an operator can recover from the scheduler itself.
The decision rule is narrow and defensible: pick SQS for the lowest-complexity retry setup, pick Pub/Sub when its additional subscription and flexibility fit the platform, and consider Azure Service Bus or RabbitMQ only when their surrounding ownership model is already intentional. Then verify the alert against capacity, latency, regional availability, and the cost profile before rollout.
Top comments (0)