For customer support shipment updates, Apache Kafka retry patterns and AWS SQS solve different recovery problems. The least complex design that meets the operational bar is a durable work queue with explicit retry state, bounded attempts, and a dead-letter path. Use a log when the same shipment event must be replayed by several independently paced consumers. Use a queue when each notification is work that one worker should finish and acknowledge.
The important choice is recovery behavior, not throughput trivia. A retry that cannot explain where the failed notification is, how many times it ran, and what an operator does next is not a retry strategy. It is delayed uncertainty.
| Recovery question | Log-oriented design | Queue-oriented design |
|---|---|---|
| Who owns the next attempt? | A consumer group and its position | A worker lease and message visibility |
| How do I replay one shipment? | Seek or republish from retained event data | Redrive the failed work item |
| What happens during a slow dependency? | Partition lag grows | Visibility expires and the item becomes available again |
| What must the application persist? | Attempt timing and idempotency state | Attempt timing, receipt state, and idempotency state |
My decision rule is narrow: put the canonical shipment event in a replayable stream if many subscribers need their own pace; put each notification attempt in a work queue if the delivery is a single operational task. Mixing those roles creates the hardest incident reviews.
The limitation is plain: neither model makes an external notification exactly once.
Exactly.
What does recovery need to prove?
For customer support, “the event was published” is not the same as “the customer was notified.” A tracking event may need to reach email, SMS, an agent dashboard, and a carrier-status integration. Each destination has a different failure mode. The carrier endpoint can return 429. An email provider can accept a request while the worker times out before recording success. A support dashboard can be temporarily unavailable.
The system needs a small ledger per destination. It should record a stable shipment event ID, destination, attempt count, next eligible time, last error class, and a terminal state. I keep those fields together because an operator should answer the first incident question without searching through worker logs, then decide whether to redrive one notification or quarantine the whole destination. Keep the payload immutable; update delivery state separately. This is more state than a one-line retry loop, and that is the trade-off: recovery becomes inspectable at the cost of maintaining a delivery ledger.
At-least-once delivery is the practical baseline here. It means a consumer may see the same work more than once, so the notification operation needs an idempotency key such as shipment-8421:email:delivered. A retry policy cannot turn an external side effect into exactly-once behavior. The receiving system must tolerate a repeated request, or the application must place a deduplication boundary before the side effect.
This is the first trap. A backoff timer controls when work becomes eligible. It does not prove that the previous attempt did nothing.
Where do Kafka and SQS draw different retry boundaries?
Kafka is a retained event log. Consumer offsets describe a position in a partition, and a consumer group divides partitions among its members. That model is useful when a shipment event has several subscribers and each subscriber needs independent replay or pause behavior. It also means a poison record can hold up progress for a partition if the consumer keeps retrying it in place.
A common Kafka pattern is to publish failed work to retry topics with increasing delays, then route exhausted work to a dead-letter topic. The application owns the retry schedule and the event headers. This gives precise control, but it adds topics, routing rules, and observability. The operational cost is real. A retry topic is an application protocol, not a magic queue feature.
SQS presents a different boundary. A worker receives a message for a visibility timeout; while that timeout is active, other workers should not receive the message. If the worker fails to delete it, the message can become visible again. A redrive policy can move messages to a dead-letter queue after a configured receive count. The application still needs idempotency, because a message can be delivered more than once and a timeout can obscure the outcome of a side effect.
The distinction matters during a carrier outage. With a queue, the failed notification can wait for its visibility timeout or be explicitly returned, then be tried again. With a log, the consumer must decide how to prevent one failing record from repeatedly blocking useful records in the same partition. Neither model eliminates the incident. They put the recovery control in different places.
RabbitMQ sits closer to the queue side for this comparison: acknowledgements tell the broker whether a delivery has been accepted, rejected, or requeued, while publisher confirms cover a separate publication concern. That separation is easy to miss. A confirmed publish does not mean a downstream notification was acknowledged.
NATS JetStream offers another log-and-consumer variant, with explicit acknowledgement and redelivery concepts. It is a useful reminder that product labels do not settle the design. Ask which state is durable, which actor owns retry timing, and how a single failed shipment is isolated from healthy work.
A retry record should make the next action obvious
Here is the application boundary I use for a notification worker. It is deliberately generic. The transport adapter can sit on a queue, a retry topic, or a self-hosted broker.
type DeliveryState = "pending" | "in_flight" | "delivered" | "dead"; type NotificationAttempt = { eventId: string; shipmentId: string; destination: "email" | "sms" | "agent_dashboard"; attempt: number; nextAttemptAt: string; state: DeliveryState; idempotencyKey: string; lastError?: string; }; const MAX_ATTEMPTS = 6; function scheduleFailure( item: NotificationAttempt, error: unknown, now = Date.now() ): NotificationAttempt { const attempt = item.attempt + 1; const terminal = attempt >= MAX_ATTEMPTS; const delayMs = Math.min(15 * 60_000, 2 ** attempt * 1_000); return { ...item, attempt, state: terminal ? "dead" : "pending", nextAttemptAt: new Date(now + delayMs).toISOString(), lastError: error instanceof Error ? error.message : "unknown failure" }; } Six attempts is an example policy, not a universal truth. The number should come from the destination's outage window, customer-impact tolerance, and on-call capacity. The cap is important because an endlessly retrying shipment update can consume the same resources needed to recover the system.
That number is a policy input, not a benchmark.
Keep it visible.
Notice what the function does not do. It does not make the network call. It does not delete a message. It does not claim that the external system did not receive the request. Those actions belong in a worker transaction with a clear acknowledgment order.
A safe sequence is: claim the item, call the destination with the stable idempotency key, record the result, then acknowledge the transport delivery. If the process dies after the call and before the record or acknowledgement, the next delivery repeats the key. That is the expected failure window, not an edge case to hide.
Which failure should page an operator?
Do not page on every retry. Page on recovery evidence. A rising count of pending items is a useful signal only when grouped by destination and age. A carrier integration with 500 failed attempts in five minutes is different from 500 failed attempts spread across a day. The queue depth, oldest item age, retry rate, dead-letter count, and consumer lag belong on the same dashboard because each describes a different part of the path.
Use error classes that map to action. Authentication failures need a different owner from 429 responses. Invalid shipment data should usually become terminal after validation, while a connection reset deserves backoff. If every failure becomes unknown, the retry mechanism will keep making bad decisions with impressive consistency.
Test the ugly transitions. Kill the worker after the external call. Advance the clock past a visibility timeout. Pause one consumer. Fill a retry topic with a poison event. Redrive a dead-letter item after fixing its cause. The test is successful only when the ledger, transport state, and operator view agree about what happened.
When is the runner-up the better fit?
A queue-oriented design is the better fit when each shipment notification has one worker owner, the normal action is “try this task,” and operators need a compact dead-letter workflow. It keeps the unit of recovery close to the unit of work. Its limitation is weaker event replay semantics for a new subscriber; retain the original event elsewhere if that replay matters.
A log-oriented design is the better fit when email, SMS, dashboards, and analytics must consume the same immutable shipment event independently, or when a newly introduced subscriber needs historical events. Its cost is that every consumer owns more policy: offset management, poison-record isolation, replay controls, and lag interpretation.
A hybrid is reasonable when the event log is the source for fan-out and each destination has a queue-like retry ledger. That is two delivery systems, so document the handoff and monitor it as a first-class boundary. The architecture is justified only when independent replay and independent operational recovery are both requirements.
The final decision should fit the incident you expect to handle at 03:00. If an operator can identify one shipment, one destination, one attempt count, and one next action, the design is doing its job. Choose the boundary that makes recovery legible, then make duplicate delivery harmless.
Further reading
- Apache Kafka documentation on consumer groups and offsets: https://kafka.apache.org/documentation/#intro_consumers
- Amazon SQS documentation on visibility timeout: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html
- Amazon SQS documentation on dead-letter queues: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html
- RabbitMQ consumer acknowledgements and publisher confirms: https://www.rabbitmq.com/docs/confirms
- NATS JetStream consumers: https://docs.nats.io/nats-concepts/jetstream/consumers
- HTTP Semantics, idempotent methods: https://httpwg.org/specs/rfc9110.html#idempotent.methods