Async Patterns — Message Queues and Event-Driven Architecture
1. Why Async
| Problem | Sync Solution | Async Solution |
|---|---|---|
| Slow downstream | User waits | Job queued, 202 returned |
| Traffic spike | Drop/timeout | Queue absorbs burst |
| Transient failure | Retry in-band | Message retried from queue |
| CPU-bound work | Blocks thread | Worker pool processes in parallel |
Core benefits:
- Decouple producers and consumers — deploy/scale independently
- Absorb spikes — queue depth grows instead of requests failing
- Retry on failure — message stays in queue until acked
- Parallelism — multiple workers consume same queue
2. Sync vs Async Request Flow
sequenceDiagram
participant User
participant API
participant Worker
participant DB
Note over User,DB: Sync flow
User->>API: POST /resize-image
API->>Worker: process(image)
Worker->>DB: save result
DB-->>Worker: ok
Worker-->>API: done
API-->>User: 200 OK (waits 3s)
Note over User,DB: Async flow
User->>API: POST /resize-image
API->>DB: enqueue job
API-->>User: 202 Accepted + job_id
Worker->>DB: poll queue
Worker->>DB: save result
User->>API: GET /jobs/job_id
API-->>User: 200 completed
3. Message Delivery Guarantees
| Guarantee | Behavior | Tradeoff | Systems |
|---|---|---|---|
| At-most-once | Fire and forget, no retry | May lose messages | UDP, SNS (no DLQ), Kafka (acks=0) |
| At-least-once | Retry until acked | Duplicates possible | SQS, Kafka (acks=1/-1), RabbitMQ |
| Exactly-once | Delivered and processed once | High cost, slower | Kafka transactions, SQS FIFO + dedup |
Practical rule: build for at-least-once + idempotent consumers. Exactly-once is expensive and rarely worth it.
4. Queue Patterns
Point-to-Point
One message → one consumer. Workers compete; each message processed once.
Producer → [Queue] → Consumer A (Consumer B does not see this message)
Use: task queues, job workers. SQS standard queue.
Pub/Sub
One message → all subscribers. Fan-out.
Producer → Topic → Subscriber A
→ Subscriber B
→ Subscriber C
Use: event broadcast, notifications. SNS topics, Kafka topics.
Competing Consumers
Multiple workers on the same queue for horizontal scale.
Producer → [Queue] → Worker 1
→ Worker 2
→ Worker 3
SQS: workers call ReceiveMessage concurrently; visibility timeout prevents double-processing.
Priority Queue
High-priority messages processed before low-priority.
- RabbitMQ:
x-max-priorityqueue arg, per-messagepriorityproperty - SQS FIFO: no native priority — workaround: separate queues per priority tier, consume high-priority queue first
# RabbitMQ priority queue declaration
channel.queue_declare(queue='jobs', arguments={'x-max-priority': 10})
channel.basic_publish(
exchange='', routing_key='jobs',
body='urgent task',
properties=pika.BasicProperties(priority=9)
)
5. Dead Letter Queue (DLQ)
Messages go to DLQ when:
- Max receive count exceeded (SQS:
maxReceiveCount) - TTL expired (RabbitMQ:
x-message-ttl) - Consumer explicitly rejects without requeue
[Queue] → Worker fails 3x → [DLQ]
↓
Alert + inspect + reprocess
SQS DLQ config:
{
"RedrivePolicy": {
"deadLetterTargetArn": "arn:aws:sqs:us-east-1:123:my-dlq",
"maxReceiveCount": 3
}
}
Monitoring:
- CloudWatch alarm on
ApproximateNumberOfMessagesVisibleon DLQ > 0 - Alert immediately — DLQ message = data not processed
Reprocessing: replay DLQ messages back to source queue after fixing the consumer bug.
6. Outbox Pattern
Problem: dual-write — you need to write to DB and publish to queue atomically. If the app crashes between the two, you get inconsistency.
Solution: write event to an outbox table in the same DB transaction. Separate poller reads outbox and publishes.
Service → DB transaction:
INSERT INTO orders (...)
INSERT INTO outbox (event_type, payload, published=false)
Poller → SELECT * FROM outbox WHERE published=false
→ publish to queue
→ UPDATE outbox SET published=true
CREATE TABLE outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
published BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now()
);
Tools that implement this: Debezium (CDC-based), Transactional Outbox libraries.
Guarantee: at-least-once delivery. Poller must handle crash between publish and mark-published → idempotent consumers required.
7. Saga Pattern
Distributed transactions across microservices without 2PC. Each step has a compensation transaction to undo on failure.
Choreography (events)
Each service listens for events and emits next event. No central coordinator.
OrderService → order.created →
InventoryService → inventory.reserved →
PaymentService → payment.charged →
ShippingService → order.shipped
On failure:
PaymentService → payment.failed →
InventoryService → inventory.released →
OrderService → order.cancelled
Pros: loose coupling. Cons: hard to trace, event loops possible.
Orchestration (central coordinator)
One saga orchestrator calls each service and handles failures.
SagaOrchestrator:
1. call InventoryService.reserve()
2. call PaymentService.charge()
3. call ShippingService.ship()
on failure at step N: call compensate() for steps 1..N-1
Pros: clear flow, easy to trace. Cons: orchestrator is a dependency.
8. Saga Sequence — E-Commerce Order
sequenceDiagram
participant Orchestrator
participant Inventory
participant Payment
participant Shipping
Orchestrator->>Inventory: reserve_items(order_id)
Inventory-->>Orchestrator: reserved_ok
Orchestrator->>Payment: charge_card(order_id)
Payment-->>Orchestrator: charge_failed
Note over Orchestrator: begin compensation
Orchestrator->>Inventory: release_items(order_id)
Inventory-->>Orchestrator: released_ok
Orchestrator-->>Orchestrator: mark_order_failed(order_id)
9. Event Sourcing
State = current value (traditional) vs State = sequence of events (event sourcing).
Traditional: orders table → { id: 1, status: "shipped", total: 99 }
Event sourced: events table → OrderCreated { id:1, total:99 }
→ PaymentCharged { id:1, amount:99 }
→ OrderShipped { id:1, tracking:"XYZ" }
Replay events → current state
Benefits:
- Full audit log for free
- Rebuild read projections at any point in time
- Debug by replaying history
Tradeoffs:
- Querying current state requires replay or a projection
- Schema evolution of old events is hard
- Store grows forever (use snapshots)
Snapshot pattern: periodically store current state so replay starts from snapshot, not event 0.
10. CQRS
Separate write model (commands) from read model (queries).
Client
│
├─ Command → Write API → DB (normalized, consistent)
│ │
│ └─ async projection → Read DB (denormalized, fast)
│
└─ Query → Read API → Read DB
Write DB: optimized for consistency (Postgres, normalized).
Read DB: optimized for query patterns (Elasticsearch, Redis, denormalized Postgres view).
Eventual consistency gap: read model lags write model by milliseconds to seconds. Client must handle stale reads (show "processing" state).
CQRS + Event Sourcing pair naturally: events from write side populate read projections.
11. Backpressure
Consumer falls behind → queue grows → memory/disk exhaustion → cascade failure.
| Strategy | Behavior | Use When |
|---|---|---|
| Drop | Discard new messages | Lossy telemetry, metrics |
| Buffer | In-memory queue before consumer | Short bursts only |
| Block producer | Producer waits until consumer ready | Kafka consumer lag, TCP flow control |
| Rate limit | Reject excess with 429 | API ingestion endpoints |
| Scale consumers | Add workers | Queue depth rises (KEDA) |
KEDA + SQS autoscale:
triggers:
- type: aws-sqs-queue
metadata:
queueURL: https://sqs.us-east-1.amazonaws.com/123/my-queue
queueLength: "10" # scale up when >10 messages per replica
awsRegion: us-east-1
12. Idempotency
At-least-once delivery = consumers will see duplicate messages. Consumers must be idempotent.
Idempotency key pattern:
Producer sends: { idempotency_key: "order-123-payment", ... }
Consumer:
1. check dedup_table WHERE idempotency_key = 'order-123-payment'
2. if found → skip (already processed)
3. if not found → process + INSERT into dedup_table (in same transaction)
CREATE TABLE processed_events (
idempotency_key TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ DEFAULT now()
);
-- In consumer (single transaction):
INSERT INTO processed_events (idempotency_key) VALUES ($1)
ON CONFLICT DO NOTHING
RETURNING idempotency_key;
-- Only process if row was inserted (not a duplicate)
SQS deduplication: FIFO queues have built-in 5-minute dedup window using MessageDeduplicationId.
13. SQS Specifics
Visibility Timeout
After ReceiveMessage, message hidden from other consumers for VisibilityTimeout seconds.
- Worker must
DeleteMessagebefore timeout or message reappears - Set timeout > max processing time
- Extend with
ChangeMessageVisibilityfor long jobs
[Queue] → Worker receives msg → msg invisible (30s)
Worker finishes → DeleteMessage ✓
Worker crashes → timeout expires → msg visible again → retry
Long Polling
WaitTimeSeconds: 20 — connection held open until message arrives or 20s elapses. Reduces empty receives and cost.
sqs.receive_message(
QueueUrl=queue_url,
WaitTimeSeconds=20,
MaxNumberOfMessages=10
)
FIFO vs Standard
| Feature | Standard | FIFO |
|---|---|---|
| Throughput | Unlimited | 300 TPS (3000 with batching) |
| Ordering | Best-effort | Strict per MessageGroupId |
| Deduplication | No | Yes (5-min window) |
| Exactly-once | No | Yes |
| Use | High-throughput tasks | Order-sensitive workflows |
Message Groups (FIFO)
MessageGroupId = partition key. Messages in same group are strictly ordered. Different groups processed in parallel.
sqs.send_message(
QueueUrl=fifo_url,
MessageBody=json.dumps(event),
MessageGroupId=f"user-{user_id}",
MessageDeduplicationId=event_id
)
Delay Queues
DelaySeconds (0-900): message invisible on arrival. Use for retry backoff, scheduled tasks.
14. Kafka vs SQS vs RabbitMQ
| Feature | Kafka | SQS | RabbitMQ |
|---|---|---|---|
| Persistence | Log, configurable retention | Up to 14 days | In-memory + optional disk |
| Ordering | Per partition | FIFO: per group, Standard: none | Per queue |
| Consumer model | Pull, consumer tracks offset | Pull, visibility timeout | Push or pull |
| Replay | Yes, seek to any offset | No | No (once acked, gone) |
| Throughput | Millions/sec | Unlimited (Standard) | ~50k/sec per queue |
| Latency | Low ms | Low ms (long poll) | Sub-ms |
| Routing | Topics + partitions | Queue per pattern | Exchanges + routing keys |
| Ops overhead | High (ZooKeeper/KRaft, brokers) | Zero (managed) | Medium (self-host or CloudAMQP) |
| Multi-consumer fanout | Yes (consumer groups) | SNS + SQS fan-out | Exchanges (fanout, topic) |
| Exactly-once | Kafka transactions | SQS FIFO | With publisher confirms + manual |
Choose Kafka when: replay needed, high throughput, event sourcing, stream processing (Kafka Streams, Flink).
Choose SQS when: AWS-native, simple task queue, zero ops.
Choose RabbitMQ when: complex routing, low latency, existing AMQP ecosystem.
15. Real Examples
Order Processing Pipeline
POST /orders
→ API validates + persists order (status=pending)
→ publishes to order-events (Kafka/SNS)
Consumers (parallel):
inventory-service → reserve stock
payment-service → charge card
notification-svc → send confirmation email
payment-service failure → publishes payment.failed
→ saga compensates: inventory-service releases stock
→ order status → failed
Image Resize Pipeline
Upload API → S3 PutObject
→ S3 Event → SQS queue
Workers (ECS/Lambda):
receive message: { bucket, key, sizes: [thumb, medium, large] }
→ download original
→ resize to each size
→ upload resized to S3
→ DeleteMessage
DLQ: corrupt images, unsupported format
KEDA: scale workers based on SQS depth
Notification Service (Fan-out)
Any service → SNS topic (user.notifications)
│
├─ SQS queue (email-worker) → sends via SES
├─ SQS queue (push-worker) → sends via FCM/APNs
└─ SQS queue (sms-worker) → sends via Twilio
Each worker:
- idempotency key = notification_id + channel
- dedup before sending to avoid double-send on retry
Audit Log (Event Sourcing)
Every write API → publishes AuditEvent to Kafka (audit-log topic)
AuditEvent: {
event_id, timestamp, user_id, action,
resource_type, resource_id, before, after
}
Kafka retention: 90 days (compliance)
Consumer: writes to append-only audit_events table in Postgres
+ Elasticsearch index for search
Replay: rebuild full resource history by filtering on resource_id
Quick Reference
Decouple + absorb spikes → queue (SQS/Kafka)
Broadcast to many consumers → pub/sub (SNS, Kafka topics)
Guaranteed processing + retry → at-least-once + DLQ
Atomic DB write + publish → outbox pattern
Distributed transaction → saga (choreography or orchestration)
Full history + time travel → event sourcing
Fast reads, separate write model → CQRS
Prevent duplicate processing → idempotency key + dedup table
Consumer falling behind → backpressure (drop/block/scale/rate-limit)