It is 19:58 on the first night of a festival sale and your checkout API is a synchronous chain: order service calls inventory, which calls payments, which calls the loyalty service, which calls the email provider. The email provider’s p99 climbs to 9 seconds, every thread pool upstream fills with waiters, and by 20:04 customers cannot check out at all — not because anything your team owns is down, but because everything is welded together at request time. Event-driven architecture (EDA) is the structural answer: services record facts (“OrderPlaced”) onto a durable intermediary, and downstream consumers react on their own schedule, with their own retries, their own failures, and their own scaling. The email provider being slow becomes an email-queue backlog, not a checkout outage.
On AWS the intermediary is not one service but a toolbox: Amazon EventBridge (a serverless event bus with content-based routing), Amazon SNS (pub/sub fan-out), Amazon SQS (durable queues that buffer and meter work), AWS Lambda (the default consumer compute, wired in through event source mappings), and AWS Step Functions (orchestration when a reaction is a multi-step transaction that must complete or compensate). Each solves a different slice of the problem, each has different delivery guarantees, ordering behaviour, retry machinery and failure modes — and gluing them together naively produces the classic asynchronous horrors: lost events, duplicate side effects, poison messages blocking a FIFO group, and a DLQ nobody alarmed on quietly ageing out after 14 days.
This article is the full engineering treatment. You will learn the mental model (events vs commands, choreography vs orchestration), each service option-by-option with its real limits and defaults, the decision matrix between SNS, SQS and EventBridge, how Lambda’s pollers actually batch and retry, how to build sagas with compensation in Step Functions, why exactly-once is a marketing word and what idempotency plus the transactional outbox actually buy you, how to put a dead-letter queue (DLQ) on every hop, and how to trace one business event across five services with AWS X-Ray. Everything comes with real aws CLI, EventBridge pattern JSON and Terraform, and the reference tables are designed to be kept open during design reviews and incidents alike.
What problem this solves
Synchronous microservice chains fail collectively: availability multiplies (five 99.9% dependencies in series ≈ 99.5%), latency adds, and load spikes propagate instantly to the weakest component. Teams also couple at build time — checkout must know the loyalty service’s endpoint, payload, auth and SLA, so adding a tenth consumer of “an order happened” means changing the order service for the tenth time. Event-driven design inverts the dependency: the producer publishes a fact once; consumers subscribe without the producer knowing they exist.
Without this architecture — or with a half-understood version of it — production teams hit a predictable set of failures:
| Failure mode in a synchronous chain | Blast radius | What the event-driven counterpart does instead |
|---|---|---|
| Slow downstream (email p99 9 s) fills upstream thread pools | Checkout outage, revenue loss | Queue absorbs backlog; checkout returns in 80 ms; email drains later |
| Downstream deploy/restart window | Upstream 5xx during every release | Events buffer in SQS (up to 14 days); consumer catches up post-deploy |
| Traffic spike (10× flash sale) | Every service must scale simultaneously or fail | Buffer + metered consumption; DynamoDB/RDS protected by Lambda concurrency caps |
| Adding a new consumer of an action | Producer code change, redeploy, retest | New EventBridge rule/queue; producer untouched |
| Partial failure mid multi-step operation | Inconsistent state (charged but no order) | Step Functions saga retries or compensates deterministically |
| Retry storms after an outage | Thundering herd re-breaks the dependency | Backoff + DLQ isolates poison work; redrive on your schedule |
| Audit question: “what happened at 20:04?” | Grep five services’ logs | EventBridge archive + replay; every fact durably recorded |
The trade is new failure classes — duplicates, reordering, eventual consistency, invisible drops — which is exactly what the rest of this article weaponises you against. It bites hardest on teams decomposing a monolith into microservices, platform teams building a central integration bus, and anyone wiring SaaS webhooks (payments, CRM) into internal systems at scale.
Learning objectives
By the end of this article you can:
- Distinguish events, commands and queries, and choose choreography (EventBridge) vs orchestration (Step Functions) per workflow with defensible reasoning.
- Design EventBridge buses, write content-filtering event patterns (prefix, numeric,
anything-but,$or, wildcard), configure per-target retry policy and DLQ, and use archive/replay, pipes, scheduler and the schema registry appropriately. - Apply the SNS vs SQS vs EventBridge decision matrix and defend it in a design review.
- Configure SQS correctly: standard vs FIFO, visibility timeout sized to the consumer, maxReceiveCount and redrive, long polling, and high-throughput FIFO with message group design.
- Tune Lambda event source mappings: batch size, batching window, maximum concurrency, filters, and partial batch responses (
ReportBatchItemFailures) so one bad record doesn’t recycle a whole batch. - Build a Step Functions saga with
Retry/Catchand compensation states, and choose Standard vs Express by duration, semantics and cost. - Make every consumer idempotent (DynamoDB conditional writes, Lambda Powertools) and publish reliably with the transactional outbox pattern.
- Put a DLQ on every hop, alarm on the right CloudWatch metrics (
ApproximateAgeOfOldestMessage,DeadLetterInvocations), and trace one event end-to-end with X-Ray.
Prerequisites & where this fits
You should be comfortable with core Lambda concepts (handlers, concurrency, timeouts), IAM policies and roles, basic DynamoDB, and reading Terraform. If Lambda triggers are new, start with AWS Lambda Event-Driven Patterns — this article assumes those basics and goes several levels deeper. For the compute landscape around it, see AWS Compute: EC2, Lambda, ECS and EKS; for the data stores these pipelines land in, RDS vs DynamoDB vs Aurora.
This is an architecture-track article: it is the AWS counterpart to GCP Pub/Sub Event-Driven Architecture (useful contrast: Pub/Sub merges SNS-style fan-out and SQS-style pull into one service) and pairs directly with CQRS Read-Model Projection Pipelines, which consumes the events designed here to build query-side views. Certification relevance: this material maps to SAA-C03 (Domain 1: resilient architectures), SAP-C02 (Domain 1/3: multi-tier and integration design) and DVA-C02 (event sources, idempotency, Step Functions) — the SNS/SQS/EventBridge triangle is among the most-tested topics.
Core concepts
Before any service detail, four ideas carry the whole discipline: what an event is, who owns the flow, what delivery is actually guaranteed, and what the envelope looks like.
Events, commands and queries are different contracts
An event is an immutable statement that something happened — past tense, owned by the producer, with zero expectation about who reacts. A command is an instruction to do something — imperative, addressed to a specific handler, where the sender cares about completion. A query requests data now. Mixing these up is the root of most bad async designs: a “SendWelcomeEmail” message on a shared event bus is a command wearing an event’s clothes, and it silently couples the producer to the email team’s implementation.
| Dimension | Event | Command | Query |
|---|---|---|---|
| Naming | Past tense: OrderPlaced |
Imperative: ReserveInventory |
Interrogative: GetOrder |
| Owner of the contract | Producer (publisher) | Consumer (handler) | Data owner |
| Audience | 0…N unknown subscribers | Exactly one handler | Exactly one responder |
| Sender expectation | None — fire and forget | Completion (or failure report) | Answer, synchronously |
| Coupling introduced | None at runtime; schema only | Sender knows handler exists | Full temporal coupling |
| Natural AWS carrier | EventBridge bus, SNS topic | SQS queue, Step Functions task | API Gateway/ALB, AppSync |
| Retry semantics | Consumer’s problem, per consumer | Sender or channel must ensure | Client retries, idempotent GET |
| Failure visibility | DLQ per consumer | DLQ on the single queue | HTTP status to caller |
The practical rule: facts on the bus, work on a queue. EventBridge carries events; each consumer that must do work about the event gets its own SQS queue subscribed to the bus, so retries, backlog and DLQ policy are per-consumer, not shared.
Choreography vs orchestration
Choreography means each service reacts to events and emits its own; the flow is emergent, no one owns it. Orchestration means one component (a Step Functions state machine) explicitly calls each step, holds the state, and handles branch/retry/compensation. Neither is “correct” — they solve different shapes of flow, and mature systems use both: choreography between bounded contexts, orchestration inside a business transaction.
| Dimension | Choreography (EventBridge) | Orchestration (Step Functions) |
|---|---|---|
| Flow definition | Implicit — sum of rules and consumers | Explicit — the state machine is the flow |
| Adding a step | New rule + consumer; zero producer change | Edit the state machine definition |
| Removing/reordering steps | Hard to even discover the current order | One diff in ASL, reviewable |
| Cross-team autonomy | Excellent — teams subscribe independently | Central definition needs shared ownership |
| Failure handling | Per consumer (retry + DLQ), no global view | Retry/Catch per state + compensation path |
| “Where is order 4711 stuck?” | Correlate logs/traces across services | One execution history answers it |
| Rollback of multi-step change | Manual, event-by-event compensation | Saga: compensations encoded next to steps |
| Latency overhead | One bus hop (sub-second) per edge | State transition overhead per step |
| Cost shape | Per event published/delivered | Per state transition (Standard) or duration (Express) |
| Best for | Fan-out, integration, decoupled domains | Money movement, provisioning, anything needing “all-or-compensated” |
A useful smell test: if you find yourself building “flow tracking” tables and timeout sweepers around choreographed events, that flow wanted to be a state machine. Conversely, if a state machine’s steps are just “notify five unrelated teams”, that wanted to be a bus.
Delivery guarantees: the at-least-once reality
Every hop in this stack is at-least-once by default. Networks duplicate, pollers time out and redeliver, retries re-execute. “Exactly-once” appears with heavy qualifiers — SQS FIFO offers exactly-once processing within a 5-minute deduplication window on the producer side, and Step Functions Standard offers exactly-once workflow state transitions — and neither absolves your consumer of receiving a message twice. The design consequence is non-negotiable: every consumer must be idempotent (covered in depth later).
| Hop | Guarantee | Duplicates possible? | Ordering | Your obligation |
|---|---|---|---|---|
Producer → EventBridge (PutEvents) |
At-least-once after retries; call can partially fail | Yes (client retry after ambiguous failure) | None | Check FailedEntryCount per entry; retry failed entries |
| EventBridge → target | At-least-once, up to 24 h / 185 attempts | Yes (rare platform duplicates) | None | Idempotent target; DLQ on target |
| SNS standard → subscriber | At-least-once | Yes | None | Idempotent subscriber; subscription DLQ |
| SNS FIFO → SQS | At-least-once, deduped in window | Within 5-min window: no | Per message group | Group key design |
| SQS standard → consumer | At-least-once | Yes (visibility expiry, redelivery) | Best-effort only | Idempotency + visibility ≥ processing time |
| SQS FIFO → consumer | Exactly-once processing within dedup window | After 5-min window, or consumer crash-after-side-effect: yes | Strict per message group | Delete only after durable side effect |
| Lambda async (EventBridge/SNS → Lambda) | At-least-once, internal queue + up to 2 retries | Yes | None | Idempotent handler; on-failure destination |
| Step Functions Standard | Exactly-once state transitions | Task code may still run twice under Retry |
Definition order | Idempotent task resources |
The event envelope
EventBridge imposes a standard envelope; adopt its philosophy even for payloads on SNS/SQS. A production-grade event carries identity (for idempotency), lineage (for tracing) and versioning (for evolution):
{
"version": "0",
"id": "9d7bd4f2-3a11-4e8a-b3a0-0d2a6a4d61f1",
"detail-type": "OrderPlaced",
"source": "com.meridiankart.orders",
"account": "111122223333",
"time": "2026-07-07T14:28:11Z",
"region": "ap-south-1",
"resources": ["arn:aws:dynamodb:ap-south-1:111122223333:table/orders"],
"detail": {
"eventVersion": "1.2",
"orderId": "ord_01J9ZK7QW3",
"idempotencyKey": "ord_01J9ZK7QW3#placed",
"correlationId": "req_7f3a2b",
"customerId": "cus_88412",
"amount": { "value": 4299.00, "currency": "INR" },
"items": [{ "sku": "SKU-1181", "qty": 2 }],
"occurredAt": "2026-07-07T14:28:10.812Z"
}
}
Keep events thin-ish: enough data that common consumers don’t have to call back to the producer (avoiding re-coupling), but never the full aggregate — the 256 KB entry limit, PII minimisation and schema stability all push against fat events. If a consumer occasionally needs more, include the resource ARN/ID and let it fetch; if the payload is genuinely large (documents, images), use the claim-check pattern: put the object in S3 and publish the pointer.
EventBridge deep dive: buses, rules, patterns, pipes, scheduler
EventBridge is the routing brain of AWS event architectures: a serverless bus that receives events, matches them against rules with content-based patterns, transforms and delivers to targets with per-target retry and DLQ. It evolved from CloudWatch Events — which is why the Terraform resources are still named aws_cloudwatch_event_* and the metrics live in the AWS/Events namespace.
Buses: default, custom, partner
| Bus type | Created by | Receives | Typical use | Gotcha |
|---|---|---|---|---|
| Default bus | AWS, per region, non-deletable | AWS service events (EC2 state change, GuardDuty findings, S3 notifications…) | Ops automation, audit reactions | Service events are free; don’t pollute it with app events |
| Custom bus | You (soft limit ~100/account) | Your PutEvents + cross-account forwards |
Application/domain events (orders-prod) |
Separate buses per env; resource policy controls publishers |
| Partner bus | Activated from a partner event source | SaaS events (Datadog, Auth0, Salesforce, Zendesk…) | SaaS → AWS integration without webhook infra | Partner source must be associated before events flow |
| Central “hub” bus (pattern) | You, in a shared account | Forwards from spoke-account buses | Org-wide integration hub | Bus-to-bus forwarding is deliberately restricted — design hub-and-spoke, not a mesh |
Cross-account delivery works by making another account’s bus a target of your rule, with the receiving bus’s resource policy authorising the sender. Keep the rule-of-thumb: producers publish to their bus; a forwarding rule sends to the hub; consumers subscribe in their own accounts. For multi-region resilience, global endpoints give you a Route 53-health-checked endpoint that fails PutEvents over to a secondary region with optional replication (at-least-once — replicated events will duplicate; your idempotency layer absorbs it).
Rules and event patterns: the routing language
A rule matches events with an event pattern — a JSON document where every specified field must match (implicit AND), and arrays inside a field mean OR. Since content filtering launched, patterns are a small query language:
| Operator | Syntax example | Matches | Notes / trap |
|---|---|---|---|
| Exact value | "detail-type": ["OrderPlaced"] |
Equality (case-sensitive) | Typos match nothing, silently |
| OR within field | ["OrderPlaced", "OrderAmended"] |
Any listed value | Array = OR, fields = AND |
prefix |
{"source": [{"prefix": "com.meridiankart."}]} |
String prefix | Case-sensitive |
suffix |
{"key": [{"suffix": ".csv"}]} |
String suffix | Useful for S3 object keys |
equals-ignore-case |
[{"equals-ignore-case": "orderplaced"}] |
Case-insensitive equality | For sloppy producers |
wildcard |
[{"wildcard": "*.meridiankart.com"}] |
* anywhere in string |
Beware over-broad matches |
anything-but |
[{"anything-but": ["test"]}] |
Negation | Composes: {"anything-but": {"prefix": "internal."}} |
numeric |
[{"numeric": [">=", 1000]}] |
Comparisons and ranges [">", 0, "<=", 5000] |
Only on JSON numbers, not numeric strings |
exists |
[{"exists": true}] |
Field presence/absence | Matches leaf fields only |
cidr |
[{"cidr": "10.0.0.0/24"}] |
IP within block | Handy for network/audit events |
$or |
"$or": [{"detail": {…}}, {"detail": {…}}] |
Cross-field OR | Keep patterns lean; pattern size is capped |
| Nested object | "detail": {"amount": {"currency": ["INR"]}} |
Deep field match | Missing intermediate key = no match |
A real pattern that routes high-value Indian orders to a fraud-review queue:
{
"source": ["com.meridiankart.orders"],
"detail-type": ["OrderPlaced"],
"detail": {
"amount": {
"currency": ["INR"],
"value": [{ "numeric": [">=", 50000] }]
},
"paymentMethod": [{ "anything-but": ["giftcard"] }]
}
}
Test patterns before you trust them — the single most valuable EventBridge CLI command:
aws events test-event-pattern \
--event-pattern file://pattern.json \
--event file://sample-event.json
# => { "Result": true } # false means your rule silently drops this event
Create the bus, rule and a target with production-grade retry and DLQ settings:
aws events create-event-bus --name orders-prod
aws events put-rule \
--name orders-high-value --event-bus-name orders-prod \
--event-pattern file://pattern.json --state ENABLED
aws events put-targets --rule orders-high-value --event-bus-name orders-prod \
--targets '[{
"Id": "fraud-queue",
"Arn": "arn:aws:sqs:ap-south-1:111122223333:fraud-review",
"RetryPolicy": { "MaximumRetryAttempts": 185, "MaximumEventAgeInSeconds": 86400 },
"DeadLetterConfig": { "Arn": "arn:aws:sqs:ap-south-1:111122223333:fraud-review-eb-dlq" }
}]'
aws events put-events --entries '[{
"EventBusName": "orders-prod",
"Source": "com.meridiankart.orders",
"DetailType": "OrderPlaced",
"Detail": "{\"orderId\":\"ord_01J9ZK7QW3\",\"amount\":{\"value\":52999,\"currency\":\"INR\"}}"
}]'
Note the last command’s response contains FailedEntryCount — PutEvents is a batch API (up to 10 entries) that can partially fail; code that ignores per-entry ErrorCode loses events under throttling and believes it didn’t.
Targets, input transformation, retry and DLQ
Each rule fans out to at most 5 targets (hard limit — fan out wider via SNS/SQS or multiple rules). Delivery is per-target: one target failing doesn’t affect the others.
| Target knob | Values | Default | When to change | Gotcha |
|---|---|---|---|---|
Input (static) |
Fixed JSON | Whole event | Target needs a constant payload | Discards the event entirely |
InputPath |
JSONPath, e.g. $.detail |
Whole event | Target wants only the detail | Loses envelope (id, time) — keep id for idempotency |
InputTransformer |
InputPathsMap + InputTemplate |
Whole event | Reshape for the target’s contract | Template output must be valid JSON for JSON targets |
RetryPolicy.MaximumRetryAttempts |
0–185 | 185 | Lower for latency-sensitive targets | Retries stop at attempts or age, whichever first |
RetryPolicy.MaximumEventAgeInSeconds |
60–86400 | 86400 (24 h) | Shorten when stale events are harmful | Expired events go to DLQ (if configured) or are dropped |
DeadLetterConfig |
SQS queue ARN | None | Always set it | Queue policy must allow events.amazonaws.com; without DLQ, exhausted events vanish |
| Target role | IAM role EB assumes | Resource-policy based for SQS/SNS/Lambda | Kinesis, Step Functions, cross-account bus | Missing permission = FailedInvocations metric, not an error to the producer |
| SQS FIFO target params | SqsParameters.MessageGroupId |
None | Mandatory for FIFO queue targets | Queue must enable content-based dedup, or sends fail |
| API destination rate | InvocationRateLimitPerSecond |
Per destination | Protect SaaS/webhook endpoints | Excess buffers internally up to 24 h, then DLQ/drop |
API destinations deserve a mention: they make any HTTPS endpoint a target, with a connection object handling auth (API key, Basic, OAuth client credentials) and per-endpoint rate limiting — replacing a whole class of “Lambda that calls a webhook” glue.
EventBridge Pipes: point-to-point without glue Lambdas
EventBridge Pipes connect one source to one target with optional filtering and enrichment — the managed replacement for “Lambda that reads SQS, calls an API, writes to somewhere”. Pipes are also how you get DynamoDB Streams or Kinesis data onto a bus cleanly.
| Pipe stage | Required | Options | Notes |
|---|---|---|---|
| Source | Yes | SQS, Kinesis, DynamoDB Streams, MSK, self-managed Kafka, Amazon MQ | Same batching semantics as Lambda ESM (batch size, window) |
| Filter | No | EventBridge pattern syntax | Filtered records are not billed at target; cheap pre-filter |
| Enrichment | No | Lambda, Step Functions Express, API destination, API Gateway | Synchronous call; response replaces the payload |
| Target | Yes | Most EventBridge targets incl. another bus, SFN, Kinesis, SQS | For stream sources, configure on-failure destination |
The canonical use in this article’s architecture: DynamoDB Stream → Pipe (filter INSERTs) → EventBridge bus, which turns a table write into a published event — the serverless outbox relay (see the outbox section).
EventBridge Scheduler vs scheduled rules
Old habit: cron rules on the default bus. New default: EventBridge Scheduler, a separate serverless scheduler purpose-built for it.
| Dimension | EventBridge Scheduler | Scheduled rules (legacy) |
|---|---|---|
| Expressions | rate(), cron(), one-time at() |
rate(), cron() only |
| Time zones | Native (Asia/Kolkata), DST-aware |
UTC only |
| Flexible windows | Yes — spread invocations over 1–240 min | No |
| Targets | Hundreds of services via universal targets (templated + SDK) | Standard rule targets |
| Retries + DLQ | Per schedule (retry attempts, max age, SQS DLQ) | Per target |
| Scale | Millions of schedules; one-time schedules auto-delete option | Counts against ~300 rules/bus |
| Encryption | CMK supported per schedule group | Bus-level |
| Use for | Business scheduling (“remind at 09:00 IST”), delayed actions | Leave for legacy infra automation |
Schema registry, archive and replay
The schema registry can infer schemas from events flowing on a bus (schema discovery), version them, and generate code bindings (Java/Python/TypeScript). Turn discovery on in non-prod continuously and on prod temporarily when auditing drift — discovery bills per ingested event after the free tier (first 5M discovered events/month), and exported schemas belong in your CI contract tests.
Archive and replay is your async time machine: an archive attached to a bus records events (optionally filtered by a pattern) with a retention you choose (N days or indefinite); a replay re-publishes a time-slice of the archive onto the bus, where rules process them again. Three properties matter operationally: replayed events carry a replay-name field (so consumers can branch or ignore), replay is not order-preserving relative to original arrival, and replay pressure hits every matching rule unless you scope the replay to specific rules — so design consumers to tolerate replay from day one, which idempotency already gives you.
aws events create-archive --archive-name orders-prod-all \
--event-source-arn arn:aws:events:ap-south-1:111122223333:event-bus/orders-prod \
--retention-days 90
aws events start-replay --replay-name backfill-fraud-2026-07-06 \
--event-source-arn arn:aws:events:ap-south-1:111122223333:archive/orders-prod-all \
--event-start-time 2026-07-06T00:00:00Z --event-end-time 2026-07-06T23:59:59Z \
--destination '{"Arn":"arn:aws:events:ap-south-1:111122223333:event-bus/orders-prod","FilterArns":["arn:aws:events:ap-south-1:111122223333:rule/orders-prod/orders-high-value"]}'
Limits and quotas that shape designs
| Limit | Value | Adjustable? | Design consequence |
|---|---|---|---|
| Event entry size | 256 KB | No | Claim-check via S3 for large payloads |
PutEvents batch |
10 entries per call | No | Batch in producers; check per-entry failures |
PutEvents throughput |
Regional soft quota — 10,000 req/s in the largest regions, lower defaults elsewhere | Yes | Load-test in your region; request raises before launch |
| Rules per bus | 300 (soft) | Yes | Consolidate with content filters, not one rule per consumer instance |
| Targets per rule | 5 | No | Fan out via SNS/SQS or multiple rules |
| Target retry | ≤185 attempts, ≤24 h | Per target | After that: DLQ or gone — always configure DLQ |
| Buses per account | ~100 (soft) | Yes | Bus per domain per env, not per microservice |
| Schema discovery free tier | 5M events/month | n/a | Discovery always-on in dev, selective in prod |
| Typical bus latency | Sub-second (p50 well under 1 s) | n/a | Not for request/response paths |
Terraform: the whole routing layer as code
resource "aws_cloudwatch_event_bus" "orders" {
name = "orders-prod"
}
resource "aws_cloudwatch_event_rule" "high_value" {
name = "orders-high-value"
event_bus_name = aws_cloudwatch_event_bus.orders.name
event_pattern = jsonencode({
source = ["com.meridiankart.orders"]
"detail-type" = ["OrderPlaced"]
detail = {
amount = {
currency = ["INR"]
value = [{ numeric = [">=", 50000] }]
}
}
})
}
resource "aws_cloudwatch_event_target" "fraud_queue" {
rule = aws_cloudwatch_event_rule.high_value.name
event_bus_name = aws_cloudwatch_event_bus.orders.name
arn = aws_sqs_queue.fraud_review.arn
retry_policy {
maximum_retry_attempts = 185
maximum_event_age_in_seconds = 86400
}
dead_letter_config {
arn = aws_sqs_queue.fraud_review_eb_dlq.arn
}
}
# The EB->SQS permission lives on the QUEUE policy, not IAM:
resource "aws_sqs_queue_policy" "fraud_review" {
queue_url = aws_sqs_queue.fraud_review.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "events.amazonaws.com" }
Action = "sqs:SendMessage"
Resource = aws_sqs_queue.fraud_review.arn
Condition = { ArnEquals = { "aws:SourceArn" = aws_cloudwatch_event_rule.high_value.arn } }
}]
})
}
SNS vs SQS vs EventBridge: the routing decision
The most-asked design question on AWS integration, and the answer is a matrix, not a slogan. The one-line versions: SQS is a buffer (one logical consumer, durable, metered), SNS is a megaphone (push fan-out to many, huge scale, thinner filtering), EventBridge is a router (content-based rules, SaaS/AWS integration, schema/archive machinery, modest throughput ceilings and higher latency).
| Dimension | SNS | SQS | EventBridge |
|---|---|---|---|
| Primary role | Pub/sub push fan-out | Durable pull queue / buffer | Content-routed event bus |
| Consumers per message | Up to millions of subscriptions per topic | One logical consumer (competing workers) | ≤5 targets/rule, ~300 rules/bus |
| Filtering | Filter policies on attributes or body (subset of operators) | None (consumer or ESM filter) | Richest: full pattern language on the whole event |
| Buffering / retention | None (push; retry policy then DLQ) | Up to 14 days, re-deliverable until deleted | 24 h retry per target; archive for longer |
| Ordering | FIFO topics: per message group | FIFO queues: per message group | None, ever |
| Replay | No | No (only until deleted) | Yes — archive + replay |
| Delivery model | Push: HTTP/S, Lambda, SQS, email, SMS, mobile push, Firehose | Pull: SDK, Lambda ESM, Pipes | Push to AWS targets + API destinations |
| Cross-account | Yes (subscription) | Yes (queue policy) | First-class (bus policy, bus-to-bus) |
| SaaS / AWS service events | No | No | Native (partner sources, default bus) |
| Typical latency | Tens of ms | Milliseconds (long-poll receive) | Hundreds of ms |
| Throughput | Very high (regional soft quotas); FIFO 300/s or 3,000/s batched per topic | Nearly unlimited standard; FIFO 300/s → 3,000/s batched → tens of thousands (high-throughput mode) | PutEvents regional quota (thousands/s, soft) |
| Payload max | 256 KB | 256 KB (Extended Client Library: S3-backed up to 2 GB) | 256 KB |
| DLQ | Per subscription (redrive to SQS) | Native redrive policy | Per target |
| Schema tooling | No | No | Registry + discovery + codegen |
| Price signal (us-east-1) | $0.50/M publishes | $0.40/M requests (std), $0.50/M (FIFO) | $1.00/M custom events |
Read the matrix with three tie-breakers in mind: react to AWS service state changes on the default bus (those events already arrive there, free); take strict per-entity ordering to SQS FIFO (message groups are the only ordering primitive in this trio); and keep sub-10 ms hot paths out of all three — bus and queue hops cost tens to hundreds of milliseconds by design.
The composite pattern — EventBridge for routing, SQS per consumer for execution — appears in almost every mature AWS event architecture, and it is exactly what the reference architecture later in this article shows. SNS earns its slot when you need its delivery channels (mobile push, SMS, email) or when fan-out counts exceed EventBridge’s 5-targets-per-rule comfort zone, such as broadcasting to thousands of tenant queues.
SQS deep dive: standard vs FIFO, visibility, DLQs and redrive
SQS is the workhorse: a fully managed queue with 14-day retention, per-message locking, and the redrive machinery every other service borrows for its DLQs. Its knobs are few but every one of them causes production incidents when mis-set.
Standard vs FIFO
| Dimension | Standard queue | FIFO queue |
|---|---|---|
| Throughput | Nearly unlimited (soft regional quotas) | 300 req/s per API action; 3,000 msg/s with max batching; high-throughput mode: tens of thousands/s (region-dependent) |
| Ordering | Best-effort only — will reorder under retries/scale | Strict within a MessageGroupId |
| Duplicates | At-least-once — duplicates happen | Exactly-once processing within the 5-minute dedup window |
| Dedup mechanism | None | MessageDeduplicationId or content-based (SHA-256 of body) |
| Consumer parallelism | As many workers as you like | One in-flight batch per message group |
| Naming | Any | Must end .fifo |
| DLQ pairing | Standard DLQ only | FIFO DLQ only |
| In-flight cap | ~120,000 messages | 20,000 messages |
| Price (us-east-1) | $0.40/M requests | $0.50/M requests |
| Choose when | Order doesn’t matter per item; max throughput | Per-entity ordering matters (order lifecycle, ledger, inventory per SKU) |
The FIFO trap that catches everyone: parallelism equals the number of distinct active message groups. A FIFO queue where every message carries MessageGroupId=orders is a single-file line — one worker, 300 msg/s ceiling, head-of-line blocking on any poison message. Key groups on the entity whose order matters (orderId, accountId, deviceId), never on a constant.
The settings matrix
| Setting | Values | Default | When to change | Trade-off / gotcha |
|---|---|---|---|---|
VisibilityTimeout |
0 s – 12 h | 30 s | Set to ~6× consumer timeout when Lambda consumes | Too low → duplicates mid-processing; too high → slow retry after crash |
MessageRetentionPeriod |
60 s – 14 d | 4 d | Max it (14 d) on DLQs; raise on consumer queues to survive long outages | Retention clock starts at original enqueue — moving to DLQ does not reset it |
DelaySeconds (delay queue) |
0 – 900 s | 0 | Debounce, intentional cool-down | Per-message DelaySeconds overrides on standard only (not FIFO) |
ReceiveMessageWaitTimeSeconds |
0 – 20 s | 0 (short poll) | Always 20 (long polling) | Short polling burns money on NumberOfEmptyReceives and misses messages on sparse queues |
RedrivePolicy.maxReceiveCount |
1 – 1000 | none | 3–5 for most consumers | 1 + a cold-start timeout = everything dead-letters; >10 delays poison detection |
RedrivePolicy.deadLetterTargetArn |
Queue ARN, same type + region | none | Every consumer queue | DLQ itself needs an alarm, or it’s a write-only graveyard |
RedriveAllowPolicy |
allowAll / byQueue / denyAll |
allowAll | Restrict which queues may use this DLQ | Governance for shared DLQs |
ContentBasedDeduplication (FIFO) |
on/off | off | On when producer can’t supply a dedup ID (e.g. EventBridge target) | Identical bodies within 5 min collapse — intended, but surprises testers sending the same test message |
DeduplicationScope (FIFO) |
queue / messageGroup |
queue | messageGroup required for high-throughput FIFO |
Pairs with FifoThroughputLimit=perMessageGroupId |
SqsManagedSseEnabled / KmsMasterKeyId |
SSE-SQS / SSE-KMS | SSE-SQS on (new queues) | CMK for compliance | CMK: key policy must allow producer services; wrong policy = silent delivery failure from SNS/EB |
Policy (resource policy) |
JSON | owner-only | Cross-account/service producers (SNS, EventBridge) | Missing sqs:SendMessage for the service principal = events never arrive |
Limits worth memorising
| Limit | Standard | FIFO | Consequence |
|---|---|---|---|
| Max message size | 256 KB | 256 KB | Claim-check or Extended Client Library (S3-backed, up to 2 GB) |
| Batch (send/receive/delete) | 10 msgs / 256 KB total | 10 msgs / 256 KB | Batch everywhere: 10× fewer requests = 10× cheaper |
| In-flight messages | ~120,000 | 20,000 | Hit it → OverLimit errors on receive; drain faster or add queues |
| Dedup window | n/a | 5 minutes, fixed | Duplicate sends >5 min apart are new messages |
| Message groups | n/a | Effectively unbounded | Group count = your parallelism dial |
| Long-poll wait | 20 s max | 20 s max | One connection can wait out sparse traffic |
| Visibility extension | Up to 12 h total per receive | Same | ChangeMessageVisibility for long jobs; beyond 12 h, redesign |
| Queue name | 80 chars | 80 incl. .fifo |
Encode env + domain: orders-prod-fraud-review |
Visibility timeout mechanics — where duplicates are born
When a consumer receives a message, SQS doesn’t delete it; it hides it for the visibility timeout. If the consumer finishes and calls DeleteMessage in time, done. If not — crash, timeout, GC pause, Lambda function timeout — the message reappears and another worker processes it again. This is the single biggest source of duplicates in AWS event systems, and it’s tunable:
- Rule of thumb with Lambda: queue visibility timeout ≥ 6× the function timeout (the official guidance), because the poller may hold batches briefly before invoking, and throttled invocations return batches late.
- Long jobs: heartbeat with
ChangeMessageVisibilityto extend while work progresses; on failure, set visibility to 0 to make the message immediately retryable. ApproximateReceiveCountrides on every received message — log it; a value >1 tells you this is a retry, which your idempotency layer should treat accordingly.
DLQs and redrive — the poison-message escape hatch
A redrive policy moves a message to the DLQ after maxReceiveCount failed receives. Operational rules that separate teams who survive incidents from teams who don’t:
- DLQ retention = 14 days, always. The expiry clock started when the message was first sent to the source queue; a 4-day DLQ can silently expire messages that spent 3 days retrying.
- Alarm on
ApproximateNumberOfMessagesVisible > 0on every DLQ. A DLQ without an alarm is a data-loss device with extra steps. - Redrive is now a button (and an API). After fixing the consumer bug, move messages back:
# Create queue + DLQ with redrive, long polling and sane visibility
aws sqs create-queue --queue-name orders-email-dlq \
--attributes MessageRetentionPeriod=1209600
aws sqs create-queue --queue-name orders-email \
--attributes '{
"VisibilityTimeout": "180",
"ReceiveMessageWaitTimeSeconds": "20",
"MessageRetentionPeriod": "345600",
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:ap-south-1:111122223333:orders-email-dlq\",\"maxReceiveCount\":\"5\"}"
}'
# After the fix: drain the DLQ back to the source at a controlled rate
aws sqs start-message-move-task \
--source-arn arn:aws:sqs:ap-south-1:111122223333:orders-email-dlq \
--max-number-of-messages-per-second 50
aws sqs list-message-move-tasks \
--source-arn arn:aws:sqs:ap-south-1:111122223333:orders-email-dlq
The rate limit on start-message-move-task matters: redriving 200k messages at full speed into a consumer that just recovered is how you cause the second outage of the day.
Lambda event source mappings: batching, scaling, partial batch failure
Lambda consumes queues and streams through an event source mapping (ESM) — a fleet of pollers Lambda runs for you that receives batches, invokes your function synchronously, and manages deletes/checkpoints. Understanding who retries what — the ESM, the service, or Lambda’s async queue — is the difference between designed behaviour and mystery duplicates.
Who retries what
| Integration | Invocation type | Who retries on function error | Knobs | Where failures land |
|---|---|---|---|---|
| EventBridge → Lambda | Async | Lambda async queue: up to 2 retries; EB retries only delivery rejections (throttle/5xx), up to 185×/24 h | EB RetryPolicy + Lambda MaximumRetryAttempts/MaximumEventAgeInSeconds |
Lambda on-failure destination or EB target DLQ (delivery failures) |
| SNS → Lambda | Async | Lambda async queue (2 retries); SNS retries delivery per its policy | Subscription retry policy (HTTP only), Lambda async config | Lambda on-failure destination; SNS subscription DLQ (delivery failures) |
| SQS → Lambda | ESM (sync) | Nobody retries the invoke; the message reappears after visibility timeout, up to maxReceiveCount |
Batch size/window, ScalingConfig.MaximumConcurrency, ReportBatchItemFailures |
Queue’s DLQ |
| Kinesis / DynamoDB Streams → Lambda | ESM (sync) | ESM retries the shard batch until success, retry limit, or record age | MaximumRetryAttempts, MaximumRecordAgeInSeconds, BisectBatchOnFunctionError, parallelization factor |
DestinationConfig.OnFailure (SQS/SNS/S3) — metadata only, not the records |
| Step Functions → Lambda | Sync (Request-Response) | The state machine, per your Retry blocks |
ASL Retry/Catch |
Catch path / execution failure |
| API Gateway → Lambda | Sync | The caller | Client retries | HTTP response |
Two structural consequences: with SQS, your function must finish inside the visibility timeout or you get concurrent duplicate processing; with async sources (EventBridge/SNS), Lambda’s async event age (default 6 h, max) and 2 retries mean sustained throttling can drop events to the on-failure destination — configure one:
aws lambda put-function-event-invoke-config \
--function-name notify-customer \
--maximum-retry-attempts 2 --maximum-event-age-in-seconds 3600 \
--destination-config '{"OnFailure":{"Destination":"arn:aws:sqs:ap-south-1:111122223333:notify-async-dlq"}}'
The ESM settings matrix
| Setting | SQS | Kinesis / DynamoDB Streams | Notes |
|---|---|---|---|
BatchSize |
1–10 (FIFO ≤10); up to 10,000 for standard with a batching window | 1–10,000 records | Payload cap 6 MB per invoke regardless |
MaximumBatchingWindowInSeconds |
0–300 | 0–300 | Required (>0) for standard batch >10; adds latency, cuts invokes |
FilterCriteria |
Up to 5 patterns (EventBridge syntax) | Same | SQS: non-matching messages are deleted, not skipped — filtered ≠ retained |
ScalingConfig.MaximumConcurrency |
2–1,000 | n/a (use parallelization) | The backpressure valve protecting downstream DBs |
ParallelizationFactor |
n/a | 1–10 concurrent batches per shard | Preserves per-partition-key order within a shard |
ReportBatchItemFailures |
Yes | Yes | Enable and implement the response contract (below) |
MaximumRetryAttempts |
n/a (queue redrive governs) | -1 (infinite) to 10,000 | Streams block the shard while retrying — set it |
MaximumRecordAgeInSeconds |
n/a (queue retention governs) | -1, or 60–604,800 | Skip records too old to matter |
BisectBatchOnFunctionError |
n/a | true/false | Binary-searches the poison record; multiplies re-processing of good records — pair with idempotency |
DestinationConfig.OnFailure |
n/a | SQS / SNS / S3 | Receives shard metadata pointers, not payloads — fetch from the stream before it ages out |
TumblingWindowInSeconds |
n/a | 0–900 | Stateful aggregations across invokes |
Scaling behaviour differs fundamentally: for SQS standard, Lambda starts with a handful of concurrent batches and scales up aggressively as backlog grows (hundreds of additional concurrency per minute), bounded by MaximumConcurrency, reserved concurrency and the account limit. For SQS FIFO, concurrency ≤ number of active message groups. For streams, concurrency = shards × parallelization factor — you scale by resharding, not by backlog.
Partial batch failure: the contract most teams get wrong
Default behaviour: your function throws on record 17 of 25 → the entire batch returns to the queue → 24 successfully processed messages get reprocessed (hello, duplicate emails) and will eventually dead-letter alongside the poison one. Partial batch responses fix this — but only if you both enable ReportBatchItemFailures on the ESM and return the right shape:
def handler(event, context):
failures = []
for record in event["Records"]:
try:
process(record) # idempotent by design
except Exception:
failures.append({"itemIdentifier": record["messageId"]})
# SQS: only these return to the queue; the rest are deleted.
# Streams: checkpoint advances to just before the FIRST failure.
return {"batchItemFailures": failures}
The semantics you must internalise:
| Your response | Effect on SQS | Effect on streams |
|---|---|---|
{"batchItemFailures": []} |
Whole batch deleted | Checkpoint past the batch |
List of itemIdentifiers |
Only those messages return (receive count +1 each) | Checkpoint stops before the lowest failed sequence number; later records re-delivered |
| Malformed response / unknown ID | Treated as total batch failure | Same |
| Unhandled exception | Whole batch returns after visibility timeout | Whole batch retried |
aws lambda create-event-source-mapping \
--function-name process-order \
--event-source-arn arn:aws:sqs:ap-south-1:111122223333:orders-email \
--batch-size 25 --maximum-batching-window-in-seconds 5 \
--function-response-types ReportBatchItemFailures \
--scaling-config MaximumConcurrency=50
resource "aws_lambda_event_source_mapping" "orders_email" {
event_source_arn = aws_sqs_queue.orders_email.arn
function_name = aws_lambda_function.process_order.arn
batch_size = 25
maximum_batching_window_in_seconds = 5
function_response_types = ["ReportBatchItemFailures"]
scaling_config {
maximum_concurrency = 50
}
}
Step Functions: standard vs express, sagas and compensation
When the reaction to an event is itself a multi-step business transaction — reserve inventory, charge payment, create shipment — choreographing it as more events scatters the failure handling. AWS Step Functions makes the flow explicit: a state machine defined in ASL (Amazon States Language), with per-state retry, catch, timeouts, parallelism and human-readable execution history.
Standard vs Express
| Dimension | Standard | Express |
|---|---|---|
| Max duration | 1 year | 5 minutes |
| Execution semantics | Exactly-once state transitions | Async: at-least-once (may run twice!); sync: at-most-once |
| Billing | $25 per million state transitions | $1.00/M requests + GB-second duration |
| History | 25,000-event execution history, 90-day console visibility | CloudWatch Logs only |
Callbacks (.waitForTaskToken) |
Yes | No |
Job-run pattern (.sync) |
Yes | No |
| Start rate | Lower (thousands/s burst, soft) | ~100,000/s class (soft) |
| Idempotent start | Yes — execution name dedups for 90 days | No name-based dedup |
| Use for | Sagas, money movement, human approval, long waits | High-volume transforms, event enrichment, API backends |
Two traps hide in that table. First, Express-async runs at-least-once: an Express workflow with non-idempotent side effects can execute those side effects twice — sagas with real money belong on Standard. Second, Standard’s 25,000-event history limit kills naive Map/loop designs over big datasets; use a Distributed Map (up to 10,000 concurrent child workflows, each with its own history) or chunk via S3.
Retry and Catch: the error-handling fields
| Field | Meaning | Default | Range / notes |
|---|---|---|---|
ErrorEquals |
Error names this rule matches | — | Custom strings, Lambda.TooManyRequestsException, or States.ALL |
IntervalSeconds |
Delay before first retry | 1 | Positive integer |
BackoffRate |
Multiplier per attempt | 2.0 | 1.0 = fixed interval |
MaxAttempts |
Retry count | 3 | 0 disables; large values + backoff can exceed state TimeoutSeconds |
MaxDelaySeconds |
Cap on grown interval | none | Stops exponential blow-up on long retries |
JitterStrategy |
FULL or NONE |
NONE | FULL decorrelates retry storms — use it |
Catch[].ErrorEquals / Next |
Route matched failures to a state | — | Order matters: first match wins; put States.ALL last |
Catch[].ResultPath |
Where the error object lands in state | replaces input | "$.error" preserves the input for the compensation path |
TimeoutSeconds / HeartbeatSeconds |
State-level timeout / heartbeat | 99999999 (effectively none) | Always set TimeoutSeconds — a hung integration otherwise stalls a Standard execution for up to a year |
Canonical error names you’ll match on: States.Timeout, States.TaskFailed, States.Permissions, States.HeartbeatTimeout, States.DataLimitExceeded (the 256 KB inter-state payload cap), plus the Lambda-specific transient trio Lambda.ServiceException, Lambda.AWSLambdaException, Lambda.SdkClientException, Lambda.TooManyRequestsException — retry those; don’t retry your own validation errors.
The saga pattern: compensate, don’t rollback
A saga decomposes a distributed transaction into local transactions, each with a compensating action that semantically undoes it. Step Functions expresses this naturally: forward steps chained with Catch routes into a compensation chain that runs in reverse order of what succeeded.
| Forward step | Local transaction | Failure it may hit | Compensation |
|---|---|---|---|
| 1. Reserve inventory | Conditional decrement in DynamoDB | Out of stock | (first step — nothing to compensate) |
| 2. Charge payment | Payment gateway capture | Card declined, gateway timeout | Release inventory reservation |
| 3. Create shipment | Carrier API booking | Address unserviceable | Refund payment → release inventory |
4. Publish OrderConfirmed |
PutEvents |
Throttled (retryable) | Compensations are themselves idempotent + retried |
{
"StartAt": "ReserveInventory",
"States": {
"ReserveInventory": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "reserve-inventory", "Payload.$": "$" },
"TimeoutSeconds": 30,
"Retry": [{
"ErrorEquals": ["Lambda.ServiceException", "Lambda.TooManyRequestsException"],
"IntervalSeconds": 2, "MaxAttempts": 4, "BackoffRate": 2.0, "JitterStrategy": "FULL"
}],
"Catch": [{ "ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "OrderFailed" }],
"Next": "ChargePayment"
},
"ChargePayment": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "charge-payment", "Payload.$": "$" },
"TimeoutSeconds": 60,
"Retry": [{ "ErrorEquals": ["GatewayTimeout"], "IntervalSeconds": 3, "MaxAttempts": 3, "BackoffRate": 2.0 }],
"Catch": [{ "ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "ReleaseInventory" }],
"Next": "CreateShipment"
},
"CreateShipment": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "create-shipment", "Payload.$": "$" },
"TimeoutSeconds": 45,
"Catch": [{ "ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "RefundPayment" }],
"Next": "OrderConfirmed"
},
"RefundPayment": { "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "refund-payment", "Payload.$": "$" },
"Retry": [{ "ErrorEquals": ["States.ALL"], "IntervalSeconds": 5, "MaxAttempts": 5, "BackoffRate": 2.0 }],
"Next": "ReleaseInventory" },
"ReleaseInventory": { "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "release-inventory", "Payload.$": "$" },
"Retry": [{ "ErrorEquals": ["States.ALL"], "IntervalSeconds": 5, "MaxAttempts": 5, "BackoffRate": 2.0 }],
"Next": "OrderFailed" },
"OrderFailed": { "Type": "Fail", "Error": "OrderSagaFailed", "Cause": "Compensated" },
"OrderConfirmed": { "Type": "Succeed" }
}
}
Design rules for sagas that hold up: compensations must be idempotent and aggressively retried (a refund that fails is a lawsuit, not a log line); use semantic locks (mark the order PENDING so other flows don’t act on half-done state); and start the saga from the event queue with an idempotent execution name (order-{orderId}) so a duplicate OrderPlaced event cannot spawn a second saga — Standard workflows reject duplicate names for 90 days, one of the most underrated dedup mechanisms on AWS.
Wire the saga to the bus directly (EventBridge target type StartExecution) or — better for bursty load — EventBridge → SQS → Lambda → StartExecution, which lets you meter saga starts against the Standard start-rate quota.
Idempotency, ordering and the outbox pattern
Idempotency: the tax at-least-once charges
An operation is idempotent when applying it twice has the same effect as once. Every consumer in this architecture needs it. The strategies, strongest-first:
| Strategy | How | Cost | Failure window | Use when |
|---|---|---|---|---|
| Naturally idempotent write | PUT semantics: set status, upsert by key |
Free | None | Always prefer designing operations this way |
| DynamoDB conditional write | attribute_not_exists(pk) on an idempotency record, TTL after N days |
1 WCU per event | None (atomic) | Side effects that must fire once (email, charge) |
| Lambda Powertools idempotency | Decorator + DynamoDB persistence layer; caches result, locks in-progress | ~2 ops/event | Handles crash-mid-execution via INPROGRESS state |
Standardising across a team |
FIFO MessageDeduplicationId |
Producer-side dedup, 5-min window | Free | Duplicates >5 min apart pass | Producer retry dedup, not consumer logic |
| Optimistic versioning | ConditionExpression: version = :expected |
Free | Rejects stale/duplicate updates | Event-carried state updates, projections |
The DynamoDB pattern in six lines — the whole trick is that the check and the claim are one atomic operation:
import boto3, botocore, time
ddb = boto3.client("dynamodb")
def process_once(idempotency_key: str, event) -> bool:
try:
ddb.put_item(
TableName="idempotency",
Item={"pk": {"S": idempotency_key},
"expiresAt": {"N": str(int(time.time()) + 7 * 86400)}},
ConditionExpression="attribute_not_exists(pk)")
except botocore.exceptions.ClientError as e:
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
return False # duplicate — skip side effects
raise
do_side_effects(event) # charge, email, ship…
return True
Key selection is the design decision: use a business key (orderId#placed), not the transport’s message ID — EventBridge assigns a new id on every retry-with-republish and replay, SQS assigns a new messageId when the same logical event is re-sent, so transport IDs dedup less than you think. And write the idempotency record with a TTL (7–30 days) or the table becomes an unbounded bill.
Ordering: where it exists and where it’s a myth
| Service | Ordering unit | Guarantee | Reordering risk you still carry |
|---|---|---|---|
| EventBridge | None | None — rules may deliver in any order | Two rapid updates can arrive reversed |
| SNS standard | None | None | Same |
| SNS FIFO → SQS FIFO | Message group | Strict per group | Cross-group order undefined (by design) |
| SQS standard | None | Best-effort; retries reorder | Consumer scale-out reorders further |
| SQS FIFO | MessageGroupId |
Strict per group while in-order consumption holds | A dead-lettered message leaves the group — later messages then proceed: gap, not stall |
| Kinesis / DynamoDB Streams | Shard / partition key (item) | Strict per key | Resharding boundaries; cross-shard undefined |
| Lambda concurrent consumers | n/a | Destroys ordering unless FIFO/stream constraints hold | Concurrency 1 is the last resort, not a strategy |
Because global ordering doesn’t exist, resilient consumers use version-aware writes instead of relying on arrival order: every event carries the aggregate version (or occurredAt), and the consumer applies it only if newer than what it has (ConditionExpression: version < :incoming). That single habit converts “strict ordering required” into “per-entity eventual convergence” — a far cheaper property to operate. Reserve true FIFO for flows where intermediate states matter (ledgers, inventory counts), not just final state.
The dual-write problem and the transactional outbox
The bug: your service writes the order to its database and then publishes OrderPlaced. Crash between the two → order exists, event never sent, downstream never knows. Reverse the order → event sent, DB write fails, downstream believes in a phantom order. Any design with two independent writes has this hole; retries don’t fix it, they just move it.
The transactional outbox closes it: write the state change and the event into the same atomic transaction (an outbox table/attribute alongside the business rows), then a separate relay reads committed outbox rows and publishes them. The event now exists if-and-only-if the state change committed.
| Outbox variant | Relay mechanism | Latency | Notes |
|---|---|---|---|
| Polling relay | Scheduled worker scans outbox WHERE published = false |
Seconds | Simple, works on RDS/any DB; ensure FOR UPDATE SKIP LOCKED style claiming |
| CDC relay (DynamoDB) | Table write → DynamoDB Stream → EventBridge Pipe → bus | Sub-second | Serverless-native; the stream is the outbox — no second table needed |
| CDC relay (RDS/Aurora) | DMS/Debezium reads WAL/binlog → publishes | Sub-second–seconds | Heavier ops; right for high-volume relational sources |
The DynamoDB flavour is beautifully small — one Pipe replaces the entire relay:
resource "aws_pipes_pipe" "orders_outbox" {
name = "orders-outbox-relay"
role_arn = aws_iam_role.pipe.arn
source = aws_dynamodb_table.orders.stream_arn
target = aws_cloudwatch_event_bus.orders.arn
source_parameters {
dynamodb_stream_parameters {
starting_position = "LATEST"
batch_size = 10
maximum_retry_attempts = 8
dead_letter_config { arn = aws_sqs_queue.outbox_pipe_dlq.arn }
}
filter_criteria {
filter { pattern = jsonencode({ eventName = ["INSERT"] }) }
}
}
target_parameters {
eventbridge_event_bus_parameters {
source = "com.meridiankart.orders"
detail_type = "OrderPlaced"
}
}
}
Note what the outbox does not give you: it guarantees at-least-once publication, not exactly-once — the relay can crash after publishing and before checkpointing. Producer-side outbox + consumer-side idempotency is the complete, honest answer to “exactly-once on AWS”: effectively-once effects, built from two at-least-once halves.
Error handling and DLQ strategy per hop
Every hop needs an answer to three questions: who retries, for how long, and where does the event go when retries exhaust. Design it as a table — literally this table, filled in per pipeline, belongs in your runbook:
| Hop | Failure mode | Retry (who / how long) | Dead-letter destination | Alarm on |
|---|---|---|---|---|
Producer → bus (PutEvents) |
Throttle, partial batch failure | SDK adaptive retry; producer must re-send failed entries | Producer-local buffer/outbox (no server-side DLQ exists here) | Producer error logs, PutEvents p99 |
| Bus rule → SQS target | Queue policy missing, KMS denied | EventBridge, ≤185× / ≤24 h | Rule target DLQ (SQS) | FailedInvocations, DeadLetterInvocations (AWS/Events) |
| Bus rule → Lambda (async) | Function error | Lambda async: 2 retries | Lambda on-failure destination | AsyncEventsDropped, DeadLetterErrors |
| Bus rule → API destination | Endpoint 4xx/5xx, rate limit | EventBridge per retry policy; 429/5xx retried, most 4xx not | Rule target DLQ | InvocationsFailedToBeSentToDlq, endpoint 4xx logs |
| SNS → subscriber | Delivery failure | SNS delivery policy (HTTP backoff) | Subscription DLQ (SQS) | NumberOfNotificationsFailed |
| SQS → Lambda ESM | Handler exception/timeout | Redelivery after visibility timeout, maxReceiveCount times |
Queue redrive DLQ | DLQ ApproximateNumberOfMessagesVisible, source ApproximateAgeOfOldestMessage |
| Kinesis/DDB stream → Lambda | Poison record blocks shard | ESM until retry/age limits; bisect optional | On-failure destination (metadata only) | IteratorAge |
| Step Functions task | Task failure/timeout | ASL Retry per state |
Catch → compensation / Fail state; EB rule on Execution Status Change |
ExecutionsFailed, ExecutionsTimedOut |
Three principles turn that table into policy. (1) Retryable vs terminal: retry throttles, timeouts and 5xx with backoff + jitter; never retry validation errors — dead-letter them immediately with a reason attribute. (2) DLQ contents differ per hop: an EventBridge target DLQ holds the original event plus error attributes (ERROR_CODE, RULE_ARN); a stream on-failure destination holds only pointers — fetch the records before stream retention expires. (3) Every DLQ gets an owner, an alarm and a redrive runbook. An unowned DLQ is where events go to die legally.
# The alarm that pages you before customers do: oldest message age on the work queue
aws cloudwatch put-metric-alarm \
--alarm-name orders-email-backlog-age \
--namespace AWS/SQS --metric-name ApproximateAgeOfOldestMessage \
--dimensions Name=QueueName,Value=orders-email \
--statistic Maximum --period 60 --evaluation-periods 5 \
--threshold 600 --comparison-operator GreaterThanThreshold \
--alarm-actions arn:aws:sns:ap-south-1:111122223333:oncall
Observability of async flows: tracing through events
A synchronous request shows up as one trace. An event-driven flow is five services, three queues and two retries — without deliberate correlation it is unobservable. You need three layers: traces (X-Ray), metrics (per-hop CloudWatch), and correlation IDs in structured logs.
X-Ray trace propagation: what connects and what breaks
| Hop | Trace continuity | Mechanism | Caveat |
|---|---|---|---|
| API Gateway → Lambda | Continuous | X-Amzn-Trace-Id header |
Enable tracing on both |
Lambda → PutEvents → EventBridge → target |
Continuous | EventBridge propagates the trace context to targets | Only for sampled requests; instrument the SDK call (X-Ray SDK / Powertools) |
| SNS → SQS → Lambda | Continuous | SNS forwards trace header into SQS AWSTraceHeader system attribute |
Enable active tracing on the topic |
| SQS → Lambda (ESM) | Linked, not parent-child | Lambda emits the consumer trace with a link to each producer trace | Batches make one consumer trace link to N producer traces — the map shows links, not one waterfall |
| Step Functions | Continuous per execution | TracingConfiguration.Enabled = true |
Each task segment appears under the execution trace |
| EventBridge archive → replay | Broken (new context) | — | Correlation ID in the detail is your only thread |
The honest summary: X-Ray (or OpenTelemetry via ADOT exporting to X-Ray) will stitch most of the path, but batching and replay force you to also carry a correlationId inside the event detail, logged as a structured field by every consumer. Then CloudWatch Logs Insights answers “what happened to order ord_01J9ZK7QW3” across every log group at once:
fields @timestamp, @log, level, msg, detail.orderId as orderId
| filter correlationId = "req_7f3a2b" or detail.correlationId = "req_7f3a2b"
| sort @timestamp asc
| limit 200
The metrics that matter per hop
| Service | Metric (namespace) | Healthy | Alarm when | It means |
|---|---|---|---|---|
| EventBridge | MatchedEvents (AWS/Events) |
Tracks producer volume | Drops to 0 with producers active | Pattern/typo silently matching nothing |
| EventBridge | FailedInvocations |
0 | > 0 | Target rejecting (permissions, KMS, missing group ID) |
| EventBridge | DeadLetterInvocations / ThrottledRules |
0 / 0 | > 0 sustained | Retries exhausted / target throttling |
| EventBridge | IngestionToInvocationStartLatency |
< 1 s | Sustained rise | Bus or target backlog building |
| SQS | ApproximateAgeOfOldestMessage |
< consumer SLA | > SLA threshold | Backlog older than your promise |
| SQS | ApproximateNumberOfMessagesVisible (DLQ) |
0 | > 0 | Poison messages arrived — investigate now |
| Lambda | Throttles, ConcurrentExecutions |
0, below cap | Throttles > 0 sustained | Concurrency valve too tight or account limit hit |
| Lambda | AsyncEventAge / AsyncEventsDropped |
Low / 0 | Rising / > 0 | Async queue backing up / events being lost |
| Lambda | IteratorAge (streams) |
< seconds | Minutes+ | Shard blocked (poison record or slow handler) |
| Step Functions | ExecutionsFailed, ExecutionsTimedOut |
0 | > 0 | Sagas failing — check compensations completed |
Throttling and backpressure: designing the flow control
Asynchronous doesn’t mean unlimited — it means you choose where the pressure accumulates. The design question for every pipeline: when input exceeds downstream capacity, which component absorbs, and which component sheds?
| Pressure point | Symptom | The lever | Trade-off |
|---|---|---|---|
PutEvents regional quota |
ThrottlingException on publish |
SDK adaptive retry mode; batch 10/call; quota raise; local buffer/outbox drain | Producer latency rises; outbox absorbs bursts invisibly |
| EventBridge → target invocation quota | ThrottledRules climbing |
Fewer, coarser rules; SQS between bus and consumer | Queue adds a hop of latency |
| Lambda concurrency (account/reserved) | Throttles, async queue growth |
ScalingConfig.MaximumConcurrency on ESM; reserved concurrency per function |
Backlog drains slower — deliberately |
| Downstream DB (RDS connection cap, DDB WCU) | Timeouts, ProvisionedThroughputExceeded |
Cap consumer concurrency so ceiling is never hit; RDS Proxy; DDB on-demand | Queue age grows during spikes — that’s the design working |
| SQS FIFO 300/s per action | ThrottlingException on send |
Batch sends (→3,000/s); high-throughput mode + perMessageGroupId scope |
High-throughput requires group-scoped dedup |
| SaaS/webhook endpoint | 429s from the API | API destination InvocationRateLimitPerSecond |
EventBridge buffers ≤24 h, then DLQ — size the rate honestly |
| Saga start rate (SFN Standard) | ExecutionThrottled |
Queue before StartExecution; Express for the hot inner loop |
Queued sagas start late; monitor queue age |
| Everything at once (flash sale) | All of the above | Load-shed at the edge (API throttling), prioritise queues (separate P1/P2 queues) | Explicit degradation beats implicit collapse |
The mental model: SQS is the only component in this stack designed to hold pressure for days — so put a queue immediately upstream of every capacity-constrained consumer, cap that consumer’s concurrency at what its downstream survives, and alarm on queue age. Backpressure you designed is a backlog metric; backpressure you didn’t design is an outage.
Architecture at a glance
The reference architecture assembles every piece of this article into one order-processing pipeline. Read it left to right. Producers — an API Gateway-fronted order service and its DynamoDB outbox relay — publish OrderPlaced facts onto a custom EventBridge bus (orders-prod), where an archive records everything for replay. Rules content-match events and fan out: high-value orders to a fraud-review FIFO queue (grouped by orderId so per-order sequence holds), notification work to an SNS topic feeding per-channel queues, and every matched target carrying its own retry policy and DLQ. Consumers pull through Lambda event source mappings tuned with batching and partial batch responses, while the order saga runs in Step Functions Standard with compensation states. Side effects land behind an idempotency table in DynamoDB (conditional writes keyed on the business idempotency key), analytics events settle into S3, and the whole flow is stitched together by X-Ray trace propagation plus per-hop CloudWatch alarms. The numbered badges mark the six decision points this article drilled into: silent pattern non-match, FIFO group/throughput design, DLQ + redrive, partial batch failure, the idempotency gate, and archive replay semantics.
Trace one event through it: checkout writes the order row; the outbox pipe publishes OrderPlaced within a second; rules deliver to the FIFO queue and fan out via SNS; the fraud Lambda fails two records of a 25-record batch and returns only those via partial batch response; the saga reserves, charges and ships — or compensates in reverse; and every side effect checks the idempotency table first, so the duplicate delivery that will eventually happen becomes a no-op. Nothing in the pipeline depends on nothing failing — every hop assumes its neighbour will, eventually, and has a numbered answer for it.
Real-world scenario: MeridianKart’s flash-sale meltdown and rebuild
MeridianKart, a Pune-based marketplace doing ~1.2 million orders/day, ran its original “event-driven” pipeline as one SNS topic (prod-events) with nine Lambda subscribers. During a February flash sale peaking at 850 orders/second, three things failed at once. The invoice Lambda — non-idempotent — double-generated 41,000 invoices because SNS redelivered during a dependency brown-out. The inventory Lambda fell behind and, with no queue in front of it, Lambda’s async retries (2, then drop) silently discarded roughly 18,000 stock decrements once the on-failure destination — never configured — had nothing to catch. And the fraud service, which had moved to a FIFO queue “for safety”, throttled at exactly 300 messages/second: the team had set MessageGroupId to the constant string "orders", serialising the entire marketplace through one message group, and wasn’t batching sends. Recovery took 11 hours, most of it spent reconciling inventory from order history because there was no replayable record of events.
The rebuild followed the reference architecture above. Orders write to DynamoDB; a Pipe relays the stream onto a custom bus (orders-prod) — the outbox pattern ending the dual-write bug that had also been quietly dropping ~0.02% of events for months. Rules route to per-consumer SQS queues: standard queues for notifications and analytics, FIFO only for inventory (grouped by sku) and payments (grouped by orderId), with batched sends lifting the FIFO ceiling to 3,000 msg/s and high-throughput mode enabled as headroom. Every queue got a DLQ with maxReceiveCount=5, a 14-day retention and an age alarm; every consumer got ReportBatchItemFailures and an idempotency check against a DynamoDB table with 14-day TTL (write cost: about ₹450/month at their volume — the cheapest insurance they buy). The checkout saga moved to Step Functions Standard with compensation states and execution names of order-{orderId}, which — to the team’s surprise — found a duplicate-event bug in week one when 2,300 StartExecution calls failed with ExecutionAlreadyExists, each one a double-charge that hadn’t happened.
The next sale peaked at 1,100 orders/second. PutEvents throttled briefly (SDK adaptive retries absorbed it), the inventory queue aged to 4 minutes before draining — watched, not feared — and the DLQs collected 62 messages, all traced to one malformed seller SKU, redriven after a one-line fix with start-message-move-task at 50 msg/s. Total incident count: zero. The architecture didn’t remove failure; it gave every failure a place to sit and a runbook to follow.
Advantages and disadvantages
| Advantages | Disadvantages |
|---|---|
| Failure isolation: a slow consumer is a backlog, not an outage | Eventual consistency: read-your-writes breaks; UX must handle “processing” states |
| Independent scaling and deploys per consumer | Duplicates and reordering are normal; idempotency is mandatory engineering overhead |
| Adding consumers costs zero producer changes | Flow becomes invisible without deliberate tracing/correlation investment |
| Burst absorption: queues hold days of backlog | Debugging spans five consoles instead of one stack trace |
| Replay/audit: archive gives a durable record of facts | At-least-once everywhere; “exactly-once” requires outbox + idempotency discipline |
| Per-hop retry with backoff, DLQs quarantine poison work | Each hop adds latency (ms → s); wrong fit for request/response paths |
| Fine-grained cost: pay per event, scale-to-zero consumers | Many small bills (per-request, per-transition) surprise at high volume — model first |
| Vendor-managed brokers: no Kafka cluster to babysit | Service quotas (FIFO 300/s, 5 targets/rule, 256 KB) shape designs and must be known upfront |
The disadvantages are not reasons to avoid EDA; they are the engineering bill for its advantages. The failure mode worth naming: teams adopt the topology (buses, queues) without the disciplines (idempotency, DLQ ownership, correlation IDs) and end up with distributed systems problems and none of the resilience payoff. Adopt the disciplines first — they’re cheap; the topology is easy afterwards.
Hands-on lab: bus → rule → queue → Lambda with DLQ, partial batch and replay
Free-tier friendly: a few hundred events cost fractions of a rupee (EventBridge bills ~$0.000001 per event; SQS/Lambda stay inside their free tiers). You need the AWS CLI v2 configured and permissions for Events, SQS, Lambda, IAM and CloudWatch. Region below is ap-south-1; adjust freely.
1. Create the bus, archive, queues.
export ACC=$(aws sts get-caller-identity --query Account --output text)
export REG=ap-south-1
aws events create-event-bus --name lab-orders
aws events create-archive --archive-name lab-orders-all \
--event-source-arn arn:aws:events:$REG:$ACC:event-bus/lab-orders --retention-days 1
aws sqs create-queue --queue-name lab-orders-dlq \
--attributes MessageRetentionPeriod=1209600
aws sqs create-queue --queue-name lab-orders-q --attributes "{
\"VisibilityTimeout\": \"60\",
\"ReceiveMessageWaitTimeSeconds\": \"20\",
\"RedrivePolicy\": \"{\\\"deadLetterTargetArn\\\":\\\"arn:aws:sqs:$REG:$ACC:lab-orders-dlq\\\",\\\"maxReceiveCount\\\":\\\"2\\\"}\"
}"
2. Rule with a content filter, SQS target, target DLQ. Save as pattern.json:
{
"source": ["lab.orders"],
"detail-type": ["OrderPlaced"],
"detail": { "amount": [{ "numeric": [">=", 500] }] }
}
aws events put-rule --name lab-high-value --event-bus-name lab-orders \
--event-pattern file://pattern.json
# Allow EventBridge to send to the queue
aws sqs set-queue-attributes --queue-url https://sqs.$REG.amazonaws.com/$ACC/lab-orders-q \
--attributes "{\"Policy\": \"{\\\"Version\\\":\\\"2012-10-17\\\",\\\"Statement\\\":[{\\\"Effect\\\":\\\"Allow\\\",\\\"Principal\\\":{\\\"Service\\\":\\\"events.amazonaws.com\\\"},\\\"Action\\\":\\\"sqs:SendMessage\\\",\\\"Resource\\\":\\\"arn:aws:sqs:$REG:$ACC:lab-orders-q\\\"}]}\"}"
aws events put-targets --rule lab-high-value --event-bus-name lab-orders \
--targets "[{\"Id\":\"q1\",\"Arn\":\"arn:aws:sqs:$REG:$ACC:lab-orders-q\",
\"RetryPolicy\":{\"MaximumRetryAttempts\":10,\"MaximumEventAgeInSeconds\":3600},
\"DeadLetterConfig\":{\"Arn\":\"arn:aws:sqs:$REG:$ACC:lab-orders-dlq\"}}]"
3. A consumer that fails on purpose — records with amount == 999 fail, proving partial batch behaviour. Save as app.py, zip, deploy:
import json
def handler(event, context):
failures = []
for r in event["Records"]:
body = json.loads(r["body"])
amount = body.get("detail", {}).get("amount", 0)
print(json.dumps({"msg": "processing", "amount": amount,
"receiveCount": r["attributes"]["ApproximateReceiveCount"]}))
if amount == 999:
failures.append({"itemIdentifier": r["messageId"]})
return {"batchItemFailures": failures}
zip fn.zip app.py
aws iam create-role --role-name lab-eda-fn --assume-role-policy-document '{
"Version":"2012-10-17","Statement":[{"Effect":"Allow",
"Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
aws iam attach-role-policy --role-name lab-eda-fn \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole
aws lambda create-function --function-name lab-eda-consumer \
--runtime python3.12 --handler app.handler --zip-file fileb://fn.zip \
--role arn:aws:iam::$ACC:role/lab-eda-fn --timeout 10
aws lambda create-event-source-mapping --function-name lab-eda-consumer \
--event-source-arn arn:aws:sqs:$REG:$ACC:lab-orders-q \
--batch-size 10 --function-response-types ReportBatchItemFailures
4. Publish test events — one below the filter (dropped by the rule), one good, one poison:
aws events put-events --entries "[
{\"EventBusName\":\"lab-orders\",\"Source\":\"lab.orders\",\"DetailType\":\"OrderPlaced\",\"Detail\":\"{\\\"orderId\\\":\\\"o-1\\\",\\\"amount\\\":100}\"},
{\"EventBusName\":\"lab-orders\",\"Source\":\"lab.orders\",\"DetailType\":\"OrderPlaced\",\"Detail\":\"{\\\"orderId\\\":\\\"o-2\\\",\\\"amount\\\":750}\"},
{\"EventBusName\":\"lab-orders\",\"Source\":\"lab.orders\",\"DetailType\":\"OrderPlaced\",\"Detail\":\"{\\\"orderId\\\":\\\"o-3\\\",\\\"amount\\\":999}\"}]"
# Expect: "FailedEntryCount": 0
5. Observe the machinery work. o-1 never reaches the queue (pattern filtered — verify with aws events test-event-pattern). o-2 processes cleanly. o-3 fails, returns via partial batch response, retries once (watch receiveCount climb in the logs), then lands in the DLQ after maxReceiveCount=2:
aws logs tail /aws/lambda/lab-eda-consumer --since 10m --follow &
sleep 90
aws sqs get-queue-attributes \
--queue-url https://sqs.$REG.amazonaws.com/$ACC/lab-orders-dlq \
--attribute-names ApproximateNumberOfMessagesVisible
# => "1" — the poison message, quarantined, with the good ones unharmed
6. Replay from the archive — the payoff step. Pretend the consumer had a bug; re-run the last hour:
aws events start-replay --replay-name lab-replay-1 \
--event-source-arn arn:aws:events:$REG:$ACC:archive/lab-orders-all \
--event-start-time $(date -u -v-1H +%Y-%m-%dT%H:%M:%SZ) \
--event-end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--destination "{\"Arn\":\"arn:aws:events:$REG:$ACC:event-bus/lab-orders\"}"
aws events describe-replay --replay-name lab-replay-1 --query State
o-2 and o-3 process again — which is exactly why the consumer logged receiveCount and why production consumers check an idempotency table.
7. Teardown.
aws lambda delete-function --function-name lab-eda-consumer
aws events remove-targets --rule lab-high-value --event-bus-name lab-orders --ids q1
aws events delete-rule --name lab-high-value --event-bus-name lab-orders
aws events delete-archive --archive-name lab-orders-all
aws events delete-event-bus --name lab-orders
aws sqs delete-queue --queue-url https://sqs.$REG.amazonaws.com/$ACC/lab-orders-q
aws sqs delete-queue --queue-url https://sqs.$REG.amazonaws.com/$ACC/lab-orders-dlq
aws iam detach-role-policy --role-name lab-eda-fn \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole
aws iam delete-role --role-name lab-eda-fn
Common mistakes & troubleshooting
The playbook, ordered by how often each one pages someone:
| # | Symptom | Root cause | Confirm (exact command / console path) | Fix |
|---|---|---|---|---|
| 1 | Events published, consumer never fires, no errors anywhere | Rule pattern silently doesn’t match (typo in detail-type, string vs number) |
aws events test-event-pattern --event-pattern file://p.json --event file://e.json → false; MatchedEvents flat at 0 |
Fix pattern; add pattern tests to CI; alarm MatchedEvents == 0 while producer active |
| 2 | FIFO pipeline caps at ~300 msg/s, senders get throttled | No batching and/or single MessageGroupId |
CloudWatch NumberOfMessagesSent plateau; code review shows constant group ID |
Batch sends (10×); group by entity ID; enable high-throughput FIFO (DeduplicationScope=messageGroup) |
| 3 | Duplicate side effects (double emails/charges) under load | Visibility timeout < processing time, so in-flight messages redeliver | Logs show same messageId with ApproximateReceiveCount > 1 while first attempt still running |
Visibility ≥ 6× function timeout; idempotency gate on side effects |
| 4 | Healthy-looking messages flood the DLQ | maxReceiveCount=1 plus cold starts/throttle-induced slow first receive |
DLQ messages process fine on redrive; receive count = 1 | Raise maxReceiveCount to 3–5; fix visibility timeout first |
| 5 | EventBridge → SQS FIFO target: FailedInvocations, nothing delivered |
Missing MessageGroupId in target SqsParameters, or content-based dedup off |
aws events list-targets-by-rule --rule r --event-bus-name b shows no SqsParameters; FailedInvocations > 0 |
Set target MessageGroupId; enable ContentBasedDeduplication on the queue |
| 6 | Messages vanish from a queue without the consumer logging them | ESM FilterCriteria mismatch — non-matching SQS messages are deleted |
aws lambda get-event-source-mapping --uuid … shows the filter; NumberOfMessagesDeleted > invocations processed |
Fix/remove the filter; route mixed traffic to separate queues instead |
| 7 | EventBridge → Lambda works in tests, drops events during incidents | No Lambda on-failure destination; async retries (2) exhausted during downstream outage | aws lambda get-function-event-invoke-config --function-name f → not found; AsyncEventsDropped > 0 |
put-function-event-invoke-config with SQS on-failure destination |
| 8 | One bad record reprocesses 24 good ones each retry cycle | ReportBatchItemFailures enabled but handler returns nothing / wrong shape |
ESM config shows the response type; logs show whole-batch retries with mixed outcomes | Return {"batchItemFailures":[{"itemIdentifier": …}]} exactly; unit-test the contract |
| 9 | FIFO consumer stops entirely; queue depth grows on one group | Poison message at the head of a message group (head-of-line blocking) | Oldest message age grows; logs show same message failing repeatedly; group ID identifiable in message attributes | Let redrive move it to DLQ (maxReceiveCount small enough); handle out-of-band; group later messages proceed |
| 10 | Cross-account events never arrive | Receiving bus resource policy missing the sender account/rule | aws events describe-event-bus --name shared-bus → check Policy; sender’s FailedInvocations > 0 |
Add bus policy allowing events:PutEvents from the sender; scope with conditions |
| 11 | SNS/EventBridge → encrypted SQS queue: silent no-delivery | CMK key policy lacks kms:GenerateDataKey/kms:Decrypt for the service principal |
Send a test message manually (works) vs via SNS (doesn’t); CloudTrail shows KMS.AccessDeniedException |
Extend key policy to sns.amazonaws.com / events.amazonaws.com; or SSE-SQS if CMK isn’t required |
| 12 | Step Functions execution stuck “Running” for hours | Task state without TimeoutSeconds waiting on a hung integration |
aws stepfunctions get-execution-history --execution-arn … --reverse-order shows TaskScheduled with no TaskSucceeded/Failed |
Set TimeoutSeconds on every Task; add HeartbeatSeconds for callbacks; alarm on execution duration p99 |
Errors you’ll meet in logs and CloudTrail, decoded:
| Error string | Where | Actually means | First move |
|---|---|---|---|
ThrottlingException on PutEvents |
Producer | Regional TPS quota hit | Adaptive retries + batching; Service Quotas raise |
ConditionalCheckFailedException |
DynamoDB consumer | Idempotency/version gate rejected a duplicate or stale write | Usually success in disguise — log at INFO, not ERROR |
ExecutionAlreadyExists |
StartExecution |
Duplicate saga start deduped by execution name | Same — this is your dedup working |
ReceiptHandleIsInvalid / MessageNotInflight |
SQS delete | Visibility expired before you deleted; someone else may be processing it | Raise visibility timeout; check for duplicate processing |
BatchRequestTooLong |
SQS send | Batch exceeds 256 KB total | Split batches by size, not just count |
Lambda.TooManyRequestsException |
SFN / async invoke | Function throttled (reserved/account concurrency) | Retry with backoff (ASL); revisit concurrency allocation |
KMS.AccessDeniedException |
SNS/EB → SQS delivery | Key policy blocks the delivering service | Fix CMK policy (see mistake #11) |
States.DataLimitExceeded |
Step Functions | Payload between states > 256 KB | Pass S3/DynamoDB pointers, not blobs |
Best practices
- Facts on the bus, work on queues: EventBridge routes; every real consumer owns an SQS queue with its own DLQ, retry posture and concurrency valve.
- Standardise the envelope: versioned
detail-type(oreventVersionin detail),idempotencyKey,correlationId,occurredAt— enforced by a shared publisher library, documented in the schema registry. - Additive-only schema evolution: add optional fields freely; renames/removals/semantic changes get a new
detail-typeversion (OrderPlaced.v2) with a deprecation window for old-rule consumers. - DLQ everything, alarm everything: every EventBridge target, SNS subscription, SQS queue and async Lambda gets a dead-letter destination, a
> 0alarm, an owner and a redrive runbook. - Idempotency by default: the consumer template includes the DynamoDB (or Powertools) idempotency gate; reviewers treat its absence like a missing auth check.
- Outbox for anything that matters: if losing the event costs money, publish via DynamoDB Streams/CDC relay, never as a second write after the transaction.
- Test event patterns in CI: golden sample events +
test-event-patternassertions catch the silent-non-match class before production does. - Size FIFO deliberately: group by the entity whose order matters; batch sends; prove peak throughput ≥ 2× forecast in a load test, or use standard + version-aware writes.
- Set
TimeoutSecondson every Step Functions Task and retry only transient errors — with jitter (JitterStrategy: FULL). - Alarm on age, not depth:
ApproximateAgeOfOldestMessageandIteratorAgemeasure SLA risk; depth alone doesn’t. - Archive from day one: a 90-day archive on every prod bus costs little and converts “we lost events” into “we replayed 40 minutes.”
- Load-test against your region’s quotas (
PutEventsTPS, FIFO throughput, Lambda concurrency, SFN start rate) and file Service Quotas raises before launch week, not during it.
Security notes
Least privilege per direction. Producers get events:PutEvents scoped to one bus ARN — and you can pin what they publish with IAM condition keys (events:source, events:detail-type), so the payments service physically cannot emit com.meridiankart.orders events. Consumers get sqs:ReceiveMessage/DeleteMessage/GetQueueAttributes on their queue only. The ESM uses the function’s execution role for SQS access — a common review miss.
Resource policies are the cross-service glue — and the attack surface. SQS queue policies must allow events.amazonaws.com/sns.amazonaws.com to SendMessage, but always with aws:SourceArn (and aws:SourceAccount) conditions to block the confused deputy — without them, anyone’s rule or topic can inject messages into your queue. Audit bus policies for over-broad Principal: "*" grants; cross-account access should enumerate account IDs or use aws:PrincipalOrgID.
Encryption. SQS defaults to SSE-SQS (free); use SSE-KMS with a CMK when compliance demands key control, and extend the key policy to every producing service principal (the #1 silent-delivery-failure cause). Custom EventBridge buses support CMK encryption at rest; SNS topics support KMS similarly. In transit is TLS throughout; for private compute, use VPC interface endpoints (com.amazonaws.region.sqs, .events, .states) so queue/bus traffic never crosses the public internet, and pin them with endpoint policies.
Data minimisation beats encryption. Events fan out to consumers you don’t control yet — keep PII out of detail (send customerId, not the address), tokenise where consumers rarely need raw values, and treat the archive as a regulated data store with retention matching your privacy obligations (a 90-day archive of PII-laden events is a GDPR/DPDP discovery problem). CloudTrail records the control plane (PutRule, PutTargets, policy changes — alert on these; a malicious rule is silent exfiltration) while data-plane visibility comes from bus logging and per-service metrics.
Cost & sizing
What drives the bill, with us-east-1 list prices (ap-south-1 within a few percent):
| Service | Billing dimension | Price | Free tier | The gotcha |
|---|---|---|---|---|
| EventBridge | Custom events published | $1.00/M (64 KB chunks) | AWS service events on default bus: free | A 200 KB event bills as 4 events |
| EventBridge Pipes | Requests | $0.40/M (64 KB chunks) | — | Filter in the pipe: filtered records still bill as pipe requests, but save target costs |
| EventBridge Scheduler | Invocations | $1.00/M | 14M/month | Cheaper and more capable than cron rules |
| Schema discovery | Ingested events | $0.10/M | 5M/month | Fine always-on for dev buses |
| SQS | Requests (send/receive/delete) | $0.40/M std, $0.50/M FIFO (64 KB chunks) | 1M/month | Empty receives bill too — long polling cuts them ~95% |
| SNS | Publishes | $0.50/M std | 1M/month | Delivery to SQS/Lambda free; HTTP/email/SMS priced per channel |
| Lambda | Requests + GB-seconds | $0.20/M + $0.0000166667/GB-s | 1M req + 400k GB-s/month | Batch size is your cost lever: 10× batch ≈ ~10× fewer invokes |
| Step Functions Standard | State transitions | $25/M | 4,000/month | A 10-state saga = 10+ transitions per order — model it |
| Step Functions Express | Requests + duration | $1.00/M + GB-s | — | Orders of magnitude cheaper for short, hot flows |
| DynamoDB (idempotency) | On-demand writes | $1.25/M WRU | 25 GB storage | TTL deletes are free — always set TTL |
| X-Ray | Traces recorded | $5.00/M | 100k/month | Sample (5–10%) in production; 100% only in dev |
A worked month at MeridianKart scale — 50M business events, each fanning to ~2.4 targets, 5M Lambda invocations (batch 10), 1M sagas averaging 12 transitions:
| Component | Volume | Monthly (USD) | Monthly (INR ≈ ₹88/$) |
|---|---|---|---|
| EventBridge custom events | 50M | $50 | ₹4,400 |
| SQS (send + batched receive/delete) | ~70M requests | $28 | ₹2,464 |
| Lambda (5M invokes, 512 MB, 250 ms avg) | 625k GB-s | $11 | ₹968 |
| Step Functions Standard (sagas) | 12M transitions | $300 | ₹26,400 |
| DynamoDB idempotency writes | 50M WRU | $63 | ₹5,544 |
| CloudWatch + X-Ray (10% sampling) | 5M traces + alarms/logs | ~$40 | ₹3,520 |
| Total | ≈ $492 | ≈ ₹43,300 |
The lesson in that table: Step Functions Standard dominates — which is why you orchestrate only the flows that need compensation semantics (here, if only 20% of orders truly needed the saga, the SFN line drops to $60), push high-volume simple reactions to plain queue+Lambda choreography, and consider Express for short internal workflows. Right-sizing levers in order of impact: batch everywhere (SQS requests and Lambda invokes both drop ~10×), sample traces, use 64 KB-conscious event payloads, and cap Lambda memory to measured need. At small scale the whole architecture is effectively free: a startup doing 100k events/month pays under $2 — less than ₹200 — for the same resilience posture.
Interview & exam questions
Q1. When would you pick EventBridge over SNS for fan-out? When you need content-based routing on the event body, SaaS/AWS-service event sources, schema registry, or archive/replay. SNS wins on raw fan-out scale (millions of subscriptions), push delivery channels (mobile/SMS/email), and lower per-message latency. Exam tell: “route by payload attributes to different targets” → EventBridge; “notify millions of endpoints” → SNS.
Q2. Why put SQS between EventBridge and Lambda instead of invoking Lambda directly? The queue adds durable buffering (14 days vs 24 h of retries), consumer-controlled pacing (MaximumConcurrency to protect downstreams), batch economics, per-consumer DLQ with redrive, and survives consumer outages longer. Direct invocation is fine for cheap, fast, idempotent reactions.
Q3. Does SQS FIFO give exactly-once delivery? No — exactly-once processing semantics within a 5-minute deduplication window, scoped to producer-side duplicate sends. A consumer that crashes after its side effect but before DeleteMessage still reprocesses the message. End-to-end effectively-once = FIFO dedup + consumer idempotency.
Q4. How do you size an SQS visibility timeout for a Lambda consumer? At least 6× the function timeout (AWS guidance), because the poller can hold batches before invoking and throttles delay processing. Too short → concurrent duplicate processing; too long → slow recovery after consumer crashes. For long jobs, heartbeat with ChangeMessageVisibility.
Q5. What does ReportBatchItemFailures change, and what must the handler do? Without it, one failed record fails the whole batch, reprocessing every good record. With it, the handler returns batchItemFailures with the failed itemIdentifiers; SQS re-delivers only those, and stream sources checkpoint up to the first failure. It must be enabled on the ESM and implemented in the response — either alone does nothing.
Q6. Standard vs Express Step Functions for a payment saga? Standard: exactly-once state transitions, execution-name dedup (90 days), 1-year duration, callbacks, full history — everything money needs. Express is at-least-once (async), 5-minute cap, cheaper per volume — right for high-throughput enrichment, wrong for non-idempotent financial steps.
Q7. Explain the saga pattern vs two-phase commit. 2PC holds locks across services for atomic commit — unavailable/unscalable across microservices and managed services. A saga is a sequence of local transactions, each with a compensating action executed in reverse on failure; consistency is eventual, availability is preserved. Step Functions encodes it with Catch routes to compensation states, which must be idempotent and retried.
Q8. What problem does the transactional outbox solve? The dual-write inconsistency: DB commit and event publish are two non-atomic operations, so a crash between them loses events or creates phantoms. Outbox writes the event in the same transaction as state, and a relay (DynamoDB Streams → Pipe → bus, or CDC) publishes after commit — at-least-once publication tied to committed state.
Q9. Where can events silently disappear in EventBridge, and how do you defend each spot? (1) PutEvents partial failures — check FailedEntryCount; (2) pattern non-match — test-event-pattern in CI + MatchedEvents alarm; (3) target failures past 24 h/185 retries — per-target DLQ; (4) Lambda async exhaustion — on-failure destination; (5) ESM filters deleting SQS messages — filter with care. Archive as the last-resort recovery for all of them.
Q10. How do you trace one business transaction across bus, queues and functions? Enable X-Ray on producers, state machines and functions — EventBridge and SNS propagate trace context; SQS→Lambda appears as linked traces. Because batching/replay break single-trace views, also carry a correlationId in the event detail, log it as a structured field everywhere, and query with Logs Insights.
Cert mapping: Q1/Q2/Q9 are SAA-C03 staples; Q3/Q4/Q5 recur in DVA-C02; Q6/Q7/Q8 are SAP-C02 scenario territory.
Quick check
- An event arrives at EventBridge but no rule fires and nothing errors. What’s the most likely cause and the one command to confirm it?
- Your Lambda takes up to 90 s; the SQS visibility timeout is 60 s. What failure will you see?
- Which combination gives effectively-once side effects: (a) FIFO alone, (b) outbox alone, © outbox + consumer idempotency, (d) exactly-once delivery flag?
- A Step Functions Express workflow charges cards and sometimes double-charges. Why?
- What should you alarm on to detect a consumer falling behind its SLA — queue depth or queue age?
Answers
- The rule’s event pattern doesn’t match (typo, type mismatch). Confirm with
aws events test-event-pattern --event-pattern file://p.json --event file://e.json—falsemeans silent drop. - Duplicate concurrent processing: at 60 s the message reappears and a second worker processes it while the first still runs; receive counts climb and side effects double. Set visibility ≥ 6× function timeout.
- ©. Delivery is at-least-once everywhere; the outbox guarantees publication, idempotency makes duplicates harmless. There is no exactly-once delivery flag.
- Express asynchronous executions run at-least-once — the whole workflow may execute twice. Money flows belong on Standard (exactly-once transitions + execution-name dedup) with idempotent tasks.
- Age (
ApproximateAgeOfOldestMessage). Depth says how much work exists; age says how late you are — a deep-but-fresh queue can be healthy, a shallow-but-old one is an SLA breach.
Glossary
- Event bus — EventBridge router that receives events and evaluates rules against them.
- Event pattern — JSON predicate (AND across fields, OR within arrays, operators like
prefix/numeric/anything-but) selecting events for a rule. - Rule target — destination (≤5 per rule) receiving matched events, with per-target input transform, retry policy and DLQ.
- EventBridge Pipes — managed point-to-point connector: source → filter → enrichment → target.
- Archive & replay — bus-attached event recording plus re-publication of a time slice; replayed events carry
replay-name. - Visibility timeout — period a received SQS message stays hidden; expiry without delete causes redelivery.
- Redrive policy / DLQ — after
maxReceiveCountfailed receives, SQS moves a message to the dead-letter queue;StartMessageMoveTaskdrains it back. - Message group — FIFO ordering unit; strict order within, parallelism across.
- Event source mapping (ESM) — Lambda’s managed poller for SQS/streams, owning batching, filters, scaling and partial-batch semantics.
- Partial batch response — handler contract (
batchItemFailures) reporting per-record failures so only those retry. - Saga — distributed transaction as local steps plus compensating actions, orchestrated here by Step Functions.
- Idempotency key — stable business identifier that makes duplicate processing a no-op via conditional writes.
- Transactional outbox — pattern committing events atomically with state, relayed to the bus afterwards (DynamoDB Streams → Pipe is the serverless form).
- Backpressure — deliberately locating where excess load accumulates (queues) and metering consumers so downstreams survive.
Next steps
- Contrast the design space with GCP Pub/Sub Event-Driven Architecture — one service where AWS uses three, with different ordering and replay trade-offs.
- Feed these events into query-side views with CQRS Read-Model Projection Pipelines & Eventual Consistency — the natural consumer of the bus you just built.
- Go deeper on consumer patterns in AWS Lambda Event-Driven Patterns, including destinations and function URLs.
- Pick the right state stores for projections and idempotency tables in RDS vs DynamoDB vs Aurora.
- Audit the control plane you now depend on — rules, targets and policies — with AWS CloudTrail & Config for Audit and Compliance.