Designing for Idempotency in Distributed Systems
Why idempotency matters in distributed systems. Practical patterns for idempotent APIs, deduplication strategies, and safe retries at scale.

Most backend engineers reach for a message queue when they need asynchronous processing. But what happens when a single event needs to trigger multiple independent workflows? Queues are point-to-point. You need fan-out, and on AWS, the SNS + SQS pattern is the canonical solution.

Consider an e-commerce order placement. When a customer completes checkout, you need to:
If each of these is a separate service, a single queue won't cut it. You'd need the publishing service to know about every consumer and send messages to each queue individually. That's tight coupling: exactly what event-driven architecture is supposed to eliminate.
Fan-out decouples the publisher from the consumers. The publisher sends one message to a topic. The topic distributes copies to all subscribers. Each subscriber processes independently. Adding a new consumer means creating a new SQS queue and subscribing it to the topic. The publisher doesn't change at all.
The pattern is straightforward:
{
"TopicArn": "arn:aws:sns:us-east-1:123456789:order-placed",
"Subscriptions": [
{ "Protocol": "sqs", "Endpoint": "arn:aws:sqs:us-east-1:123456789:email-queue" },
{ "Protocol": "sqs", "Endpoint": "arn:aws:sqs:us-east-1:123456789:inventory-queue" },
{ "Protocol": "sqs", "Endpoint": "arn:aws:sqs:us-east-1:123456789:analytics-queue" }
]
}Each queue receives its own copy of the message. If the email service is slow or down, it doesn't affect inventory updates. Each consumer scales independently. Dead letter queues catch failures per-consumer.
Design your SNS messages with a clear, stable schema. Include enough context for all consumers to avoid callback queries to the publisher:
{
"eventType": "ORDER_PLACED",
"version": "1.0",
"timestamp": "2026-03-05T14:30:00Z",
"correlationId": "abc-123-def-456",
"payload": {
"orderId": "ord-789",
"customerId": "cust-456",
"items": [{"sku": "SKU-001", "quantity": 2, "price": 29.99}],
"totalAmount": 59.98,
"currency": "USD"
}
}The correlationId is essential for tracing a single business event across all consumers. When the email service processes this message, it logs the correlation ID. When the inventory service processes its copy, it logs the same ID. During an incident, you can query your log aggregator by correlation ID and see the full processing chain across all services.
Not every subscriber needs every message. SNS supports filter policies that let you route messages selectively:
{
"FilterPolicy": {
"order_type": ["premium", "enterprise"],
"region": ["us-east-1"]
}
}This keeps queues lean and avoids wasting compute on messages a service doesn't care about. Filter policies are evaluated by SNS before delivery, so filtered-out messages never reach the queue and don't incur SQS costs.
SNS supports two filter policy scopes: MessageAttributes (default, filters on metadata) and MessageBody (filters on the payload content itself). Body-based filtering is more flexible but slightly more expensive to evaluate. For most use cases, message attributes are sufficient and more performant.
Each SQS queue provides independent retry and failure isolation:
This means a bug in the analytics consumer doesn't block order confirmations. Each failure domain is isolated.
A poison message is one that will never process successfully, no matter how many times you retry. Maybe the message has an unexpected schema, references a resource that doesn't exist, or triggers a bug in your consumer. Without a DLQ, this message blocks the queue (or wastes compute retrying indefinitely).
Configure your redrive policy to move messages to the DLQ after 3-5 failed attempts. Then monitor the DLQ:
Standard SNS + SQS is at-least-once, best-effort ordering. If you need strict ordering, use SNS FIFO with SQS FIFO queues. FIFO guarantees:
The tradeoff is throughput: FIFO topics support 300 publishes/second (with batching, up to 3,000).
In practice, most fan-out use cases don't need strict ordering. The email service doesn't care whether it processes order A before order B. The analytics service doesn't care either. If you need ordering for a specific consumer (e.g., inventory updates for the same SKU must be ordered), use FIFO for that specific queue and standard for the rest. You can mix FIFO and standard SQS subscriptions on the same SNS topic by using separate topics for ordered vs. unordered flows.
Since standard SQS is at-least-once, every consumer must handle duplicates. SQS can deliver the same message more than once, and SNS can deliver to the same queue more than once. Design your consumers to be idempotent:
MessageId or a business key as a deduplication keyTrack these metrics per queue:
SQS consumers scale horizontally. Lambda is the simplest option: configure an event source mapping with a batch size and concurrency limit. For ECS or EKS workloads, use SQS-based autoscaling based on ApproximateNumberOfMessagesVisible / numberOfConsumers.
Why idempotency matters in distributed systems. Practical patterns for idempotent APIs, deduplication strategies, and safe retries at scale.

Hands-on guide to event sourcing with Apache Kafka. Event store design, projections, snapshots, and operational realities of running it in production.

The Saga pattern for managing distributed transactions. Choreography vs. orchestration, compensation strategies, and when to choose sagas over ACID.
