AWS Architecture

Event-Driven Architecture on AWS: EventBridge, SQS, SNS, Lambda and Step Functions

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:

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 FailedEntryCountPutEvents 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:

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:

  1. 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.
  2. Alarm on ApproximateNumberOfMessagesVisible > 0 on every DLQ. A DLQ without an alarm is a data-loss device with extra steps.
  3. 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.

Reference event-driven architecture on AWS: producers (API Gateway and an order service with a DynamoDB-streams outbox) publish to a custom EventBridge bus with rules and a 90-day archive; rules fan out to an SQS FIFO work queue and SNS notification topic, each with dead-letter queues; Lambda consumers with batched event source mappings and a Step Functions saga process the work; state lands in DynamoDB behind an idempotency check, analytics in S3, with CloudWatch and X-Ray observing every hop. Numbered badges mark pattern-match, ordering, DLQ, partial-batch, idempotency and replay decision points.

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.jsonfalse; 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

  1. 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.
  2. Standardise the envelope: versioned detail-type (or eventVersion in detail), idempotencyKey, correlationId, occurredAt — enforced by a shared publisher library, documented in the schema registry.
  3. Additive-only schema evolution: add optional fields freely; renames/removals/semantic changes get a new detail-type version (OrderPlaced.v2) with a deprecation window for old-rule consumers.
  4. DLQ everything, alarm everything: every EventBridge target, SNS subscription, SQS queue and async Lambda gets a dead-letter destination, a > 0 alarm, an owner and a redrive runbook.
  5. Idempotency by default: the consumer template includes the DynamoDB (or Powertools) idempotency gate; reviewers treat its absence like a missing auth check.
  6. 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.
  7. Test event patterns in CI: golden sample events + test-event-pattern assertions catch the silent-non-match class before production does.
  8. 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.
  9. Set TimeoutSeconds on every Step Functions Task and retry only transient errors — with jitter (JitterStrategy: FULL).
  10. Alarm on age, not depth: ApproximateAgeOfOldestMessage and IteratorAge measure SLA risk; depth alone doesn’t.
  11. Archive from day one: a 90-day archive on every prod bus costs little and converts “we lost events” into “we replayed 40 minutes.”
  12. Load-test against your region’s quotas (PutEvents TPS, 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

  1. 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?
  2. Your Lambda takes up to 90 s; the SQS visibility timeout is 60 s. What failure will you see?
  3. Which combination gives effectively-once side effects: (a) FIFO alone, (b) outbox alone, © outbox + consumer idempotency, (d) exactly-once delivery flag?
  4. A Step Functions Express workflow charges cards and sometimes double-charges. Why?
  5. What should you alarm on to detect a consumer falling behind its SLA — queue depth or queue age?

Answers

  1. 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.jsonfalse means silent drop.
  2. 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.
  3. ©. Delivery is at-least-once everywhere; the outbox guarantees publication, idempotency makes duplicates harmless. There is no exactly-once delivery flag.
  4. 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.
  5. 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

Next steps

AWSEventBridgeSQSSNSLambdaStep FunctionsEvent-Driven ArchitectureServerless
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading