The closest 1:1 cloud mapping in the whole book. Service Bus → SQS, Event Grid → EventBridge, Event Hubs → Kinesis. The vocabulary changes, the patterns don't. Pick the right pipe (queue, pub/sub, event router, or stream) and the architecture mostly draws itself.
Azure messaging is famously a four-service spread: Service Bus (durable enterprise queues + topics), Event Grid (push-based event routing), Event Hubs (high-throughput ingestion), and Storage Queues (the cheap legacy option). AWS has the same four shapes - just renamed. The mappings line up so neatly that this chapter is mostly about learning the new words and the handful of places the defaults are different (visibility timeout, FIFO throughput, batching).
| What you want to do | Azure | AWS |
|---|---|---|
| Durable work queue | Service Bus Queue | SQS Standard Queue |
| Ordered, exactly-once queue | Service Bus Queue (Sessions + dedup) | SQS FIFO Queue (.fifo suffix required) |
| Topic / pub-sub fan-out | Service Bus Topic + Subscriptions | SNS Topic + Subscriptions |
| Reactive event routing (push) | Event Grid (custom topics, system topics) | EventBridge (default bus, custom buses, partner buses) |
| High-throughput stream ingest | Event Hubs | Kinesis Data Streams |
| Stream-to-storage pipeline | Event Hubs Capture → ADLS / Blob | Kinesis Data Firehose → S3 / Redshift / OpenSearch |
| Stream SQL / analytics | Azure Stream Analytics | Kinesis Data Analytics (managed Apache Flink) |
| Workflow orchestration | Logic Apps / Durable Functions | Step Functions |
| Cheap, simple queue | Storage Queue (the original, 2008) | SQS Standard (no cheaper tier exists - SQS is already pennies) |
| Schema registry | Event Hubs Schema Registry | EventBridge Schema Registry + Glue Schema Registry |
| Dead-letter destination | DLQ sub-queue on Service Bus | A separate SQS queue, referenced by redrive policy |
| Cross-region replication | Service Bus Geo-DR pairing | No built-in - use SNS cross-region subscriptions or replicate at app level |
.fifo-style marker (Azure: RequiresSession=true, AWS: queue name must end in .fifo). Both promise ordering within a group. Both cap throughput in exchange. The semantics line up almost feature-for-feature - including the gotcha that "FIFO" only orders within a session/group, not across the whole queue.
SQS comes in two flavors. Pick the wrong one and you'll spend a chapter swearing at "near-duplicate" messages.
At-least-once delivery, best-effort ordering, virtually unlimited throughput.
The default. Cheap, fast, and the queue may occasionally duplicate or reorder a message. Your consumer must be idempotent.
Use for: background work, decoupling services, retry buffers.
Strict ordering within a MessageGroupId, exactly-once processing.
Name must end in .fifo. Default throughput: 300 transactions/sec without batching, 3,000/sec with batching. "High throughput FIFO" mode lifts this to 70,000+/sec by spreading across groups.
Use for: financial transactions, anything order-sensitive.
A second queue that receives messages after N failed receives.
Not a queue type, just a redrive policy pointing one queue at another. Set maxReceiveCount = 5 and after 5 polls without deletion the message moves to the DLQ for human inspection.
Always set one. The first time a message is poisoned, you'll thank yourself.
# Create namespace then queue
az servicebus namespace create \
--name sb-orders \
--resource-group rg-app \
--sku Standard
az servicebus queue create \
--namespace-name sb-orders \
--resource-group rg-app \
--name order-intake \
--max-delivery-count 5 \
--lock-duration PT30S \
--enable-dead-lettering-on-message-expiration true
# Dead letter is a sub-entity:
# order-intake/$DeadLetterQueue
# 1. Create the DLQ first (you need its ARN)
aws sqs create-queue \
--queue-name order-intake-dlq
DLQ_ARN=$(aws sqs get-queue-attributes \
--queue-url <dlq-url> \
--attribute-names QueueArn \
--query 'Attributes.QueueArn' --output text)
# 2. Create the main queue, point at DLQ
aws sqs create-queue \
--queue-name order-intake \
--attributes "{
\"VisibilityTimeout\": \"30\",
\"RedrivePolicy\": \"{\\\"deadLetterTargetArn\\\":\\\"$DLQ_ARN\\\",\\\"maxReceiveCount\\\":\\\"5\\\"}\"
}"
# Send a message
aws sqs send-message \
--queue-url https://sqs.us-east-1.amazonaws.com/123/order-intake \
--message-body '{"orderId":"o-001","amount":42.50}'
# Poll for messages (long polling - waits up to 20s)
aws sqs receive-message \
--queue-url https://sqs.us-east-1.amazonaws.com/123/order-intake \
--wait-time-seconds 20 \
--max-number-of-messages 10
# Returns Messages[].ReceiptHandle - you NEED this to delete
# Delete after successful processing
aws sqs delete-message \
--queue-url https://sqs.us-east-1.amazonaws.com/123/order-intake \
--receipt-handle "AQEB..."
A new hire writes a consumer loop. They keep seeing the same handful of order IDs in the logs, over and over - every 30-60 seconds. The queue's ApproximateNumberOfMessages metric stays flat at 4. The Lambda the consumer wraps is succeeding (CloudWatch shows no errors). What's wrong with this script?
#!/usr/bin/env bash
QUEUE_URL="https://sqs.us-east-1.amazonaws.com/123/order-intake"
while true; do
MSGS=$(aws sqs receive-message \
--queue-url "$QUEUE_URL" \
--max-number-of-messages 10)
# Extract bodies and process them
echo "$MSGS" | jq -r '.Messages[]?.Body' | while read -r body; do
process_order "$body" # succeeds!
done
done
delete-message call.
The script reads messages and processes them - but never tells SQS "I'm done". After the 30-second visibility timeout expires, those messages reappear in the queue, the loop polls them again, and the cycle repeats forever. The "queue won't drain" is misleading - the queue never knew anything got finished.
Fix: capture the ReceiptHandle for each message and call aws sqs delete-message --receipt-handle <handle> right after process_order succeeds. After ~5 visibility-timeout cycles, the message hits maxReceiveCount and lands in the DLQ - so you'll usually notice this bug by watching the DLQ depth climb.
Pro tip: SDKs for Python (boto3) and Go don't auto-delete either. The only thing that auto-deletes is Lambda's SQS event source mapping if the Lambda returns success - that's why Lambda+SQS is so popular.
A payments startup built a "charge customer" workflow on SQS + Lambda. The SQS queue's visibility timeout was left at the default (30 seconds). The Lambda function timeout was set to 60 seconds because the credit-card processor (Stripe) was sometimes slow on weekends.
On a Saturday, Stripe latency spiked. Lambda invocations started taking 40-55 seconds each. The message would still be in-flight in SQS when the visibility timeout (30s) expired - so SQS made the same message visible again. Lambda's event source mapping promptly polled it and invoked a second instance of the function for the same message. Both instances called Stripe. Both succeeded. The customer got charged twice.
The cascade got worse: with two in-flight Lambdas each taking 50 seconds, both eventually went over their own visibility timeout too, and the message got polled a third and fourth time. By the time monitoring caught it, the worst-affected customer had been charged 8 times for one order.
Lesson: visibility timeout must be at least 2-3x the Lambda function timeout, and Lambda should always check-then-act on a stored idempotency key (e.g. DynamoDB conditional write keyed on orderId). AWS docs say "set visibility timeout greater than your processing time" - in practice, "greater than the function's maximum timeout" is the safe rule. The startup paid 8x refunds plus interchange fees on the duplicate transactions.
SNS is push-based pub/sub. You publish to a topic; the topic delivers to every subscription. Compare to Service Bus Topics + Subscriptions - same shape, different vocab.
Pure SNS is fire-and-forget; the consumer must be online to receive. The standard trick is to subscribe SQS queues to the topic. Each consumer reads from its own queue, processes at its own pace, and SNS becomes the broadcast bus while SQS becomes the durable buffer.
# 1. Create the topic
TOPIC_ARN=$(aws sns create-topic --name order-events --query 'TopicArn' --output text)
# 2. Create the consumer queue
QUEUE_URL=$(aws sqs create-queue --queue-name warehouse-orders --query 'QueueUrl' --output text)
QUEUE_ARN=$(aws sqs get-queue-attributes --queue-url "$QUEUE_URL" \
--attribute-names QueueArn --query 'Attributes.QueueArn' --output text)
# 3. Subscribe the queue to the topic
aws sns subscribe \
--topic-arn "$TOPIC_ARN" \
--protocol sqs \
--notification-endpoint "$QUEUE_ARN"
# 4. CRITICAL: give SNS permission to send to the queue
# (resource-based policy on the queue - chapter 2 territory)
aws sqs set-queue-attributes \
--queue-url "$QUEUE_URL" \
--attributes Policy='{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"sns.amazonaws.com"},"Action":"sqs:SendMessage","Resource":"'"$QUEUE_ARN"'","Condition":{"ArnEquals":{"aws:SourceArn":"'"$TOPIC_ARN"'"}}}]}'
SNS topics can list principals from other AWS accounts. The topic's access policy grants sns:Subscribe to the other account; the other account's queue policy must allow sns:SendMessage from the topic ARN. The same two-sided handshake we covered in chapter 2 for cross-account access.
EventBridge is AWS's general-purpose event bus. It started life as CloudWatch Events (handling AWS service events like "EC2 instance state changed") and grew into a routing fabric that consumes events from AWS services, partner SaaS apps (Zendesk, PagerDuty, Datadog), and your own custom apps - then routes them to targets based on JSON pattern matching.
A pipe events flow through. Three kinds:
default - receives all AWS service events (EC2, S3, etc.).
custom - your own app events.
partner - SaaS-sourced (created by the partner).
A pattern matcher attached to a bus. When an event matches, the rule fires one or more targets. Up to 5 targets per rule, 300 rules per bus.
Patterns are JSON: {"source": ["aws.ec2"], "detail-type": ["EC2 Instance State-change Notification"]}
Where the matched event goes. 30+ options: Lambda, SQS, SNS, Step Functions, Kinesis, ECS task run, API destinations (arbitrary HTTPS), another EventBridge bus.
az eventgrid event-subscription create \
--name on-blob-created \
--source-resource-id "/subscriptions/.../mystg" \
--endpoint "https://func.azurewebsites.net/api/handle" \
--endpoint-type webhook \
--included-event-types "Microsoft.Storage.BlobCreated" \
--subject-begins-with "/blobServices/default/containers/inbox/"
# 1. Define the rule with an event pattern
aws events put-rule \
--name on-object-created \
--event-pattern '{
"source": ["aws.s3"],
"detail-type": ["Object Created"],
"detail": {
"bucket": { "name": ["my-bucket"] },
"object": { "key": [{ "prefix": "inbox/" }] }
}
}'
# 2. Attach the Lambda target
aws events put-targets \
--rule on-object-created \
--targets "Id=1,Arn=arn:aws:lambda:us-east-1:123:function:handle"
# 3. Grant EventBridge permission to invoke the Lambda
aws lambda add-permission \
--function-name handle \
--statement-id allow-eventbridge \
--action lambda:InvokeFunction \
--principal events.amazonaws.com
The killer feature: rules can inspect any field of the event JSON. This is strict matching - exact string, prefix, suffix, anything-but, numeric range, IP CIDR, or null check. No regex, no SQL.
{
"source": ["com.compass.orders"],
"detail-type": ["OrderPlaced"],
"detail": {
"customer": {
"tier": ["premium", "enterprise"]
},
"amount": [{ "numeric": [">=", 1000] }],
"region": [{ "anything-but": ["sanctions-blocked"] }]
}
}
EventBridge can infer the schema of events flowing through a bus and generate strongly-typed bindings (Java, Python, TypeScript). You then call aws events put-events with that type and AWS validates. The Schema Registry is the closest AWS gets to "API contracts for events".
Partner event sources are SaaS apps that AWS has integrated. Zendesk pushes ticket-created events directly to your EventBridge bus. PagerDuty, Datadog, Auth0, Shopify - 100+ partners. The partner creates a partner event source in your account, which you "associate" with a partner event bus.
Kinesis is the AWS Event Hubs equivalent - high-throughput, multi-consumer, replay-capable stream storage. It's actually three related services with confusingly similar names.
Raw stream storage. You write records to shards; consumers read with their own checkpoint. Retention 1-365 days. Throughput per shard: 1 MB/s in, 2 MB/s out.
Closest Azure analog: Event Hubs (almost exact).
Fully-managed stream-to-storage pipe. Reads from a stream (or accepts direct puts) and writes batches to S3, Redshift, OpenSearch, Splunk, or a generic HTTP endpoint. No shards to manage; no consumer code to write.
Closest Azure analog: Event Hubs Capture.
Managed Apache Flink for stream SQL / Java jobs. Reads from streams, runs windowed aggregations, writes to streams/Firehose/Lambda.
Closest Azure analog: Stream Analytics.
| Aspect | SQS | Kinesis Data Streams |
|---|---|---|
| Delivery model | Pull, one consumer per message | Pull, many consumers each see every record |
| Retention | Up to 14 days | 1-365 days (configurable; longer costs more) |
| Replay | No - once deleted it's gone | Yes - any consumer can rewind to an earlier offset |
| Throughput limit | Unlimited (standard); 300 TPS (FIFO default) | 1 MB/s per shard write, 2 MB/s per shard read |
| Ordering | FIFO queues: ordered per MessageGroupId | Always ordered within a shard (partition key) |
| Cost model | Per request ($0.40/M API calls) | Per shard-hour + per-record PUT cost |
# Create a stream with 2 shards
aws kinesis create-stream --stream-name clickstream --shard-count 2
# Producer: send a record
aws kinesis put-record \
--stream-name clickstream \
--partition-key user-42 \
--data "$(echo -n '{"event":"click","ts":1716393600}' | base64)"
# Consumer: read from a shard (low-level - real code uses KCL)
SHARD_ITER=$(aws kinesis get-shard-iterator \
--stream-name clickstream \
--shard-id shardId-000000000000 \
--shard-iterator-type TRIM_HORIZON \
--query 'ShardIterator' --output text)
aws kinesis get-records --shard-iterator "$SHARD_ITER" --limit 100
get-records by hand. Use the Kinesis Client Library (KCL) which handles shard assignment, checkpointing (in DynamoDB), and consumer-group coordination. Or skip it entirely - Lambda's event source mapping for Kinesis handles checkpointing for you transparently.
The most common pattern in AWS messaging: "X arrives in service Y, invoke Lambda Z with it". The plumbing that makes this work is an event source mapping - a polling agent that AWS runs on your behalf inside the Lambda service, reading from SQS / Kinesis / DynamoDB Streams / MSK / MQ / DocumentDB and invoking your function with batches.
The poller fires Lambda when any of these triggers:
So if you set BatchSize=100, MaxBatchingWindow=5: the function fires when 100 messages are buffered OR after 5 seconds (whichever first). Trade-off: bigger batches = more efficiency but higher per-message latency.
aws lambda create-event-source-mapping \
--function-name process-orders \
--event-source-arn arn:aws:sqs:us-east-1:123:order-intake \
--batch-size 10 \
--maximum-batching-window-in-seconds 5 \
--function-response-types ReportBatchItemFailures # partial-batch responses
# Lambda's execution role MUST have sqs:ReceiveMessage,
# sqs:DeleteMessage, sqs:GetQueueAttributes on the queue.
--function-response-types ReportBatchItemFailures, the function can return {"batchItemFailures": [{"itemIdentifier": "msg-3"}, ...]} and only those 3 are returned to the queue. This single setting prevents the dreaded "poison message starves the queue" pattern.
Four services, overlapping pictures on the marketing diagrams. The decision tree below is what most teams actually use.
| Service | Pick when | Avoid when |
|---|---|---|
| SQS | One consumer per message; durable work distribution; need DLQ & retries. | You need to broadcast to many consumers (use SNS or EventBridge). |
| SNS | Fan-out to a small fixed set of subscribers (email, SMS, SQS, Lambda); no content routing needed. | You need stored events, replay, or content-based routing (use EventBridge or Kinesis). |
| EventBridge | Many sources (AWS services, partner SaaS, custom), routing based on event payload, low-medium throughput (~10K events/s default). | You need ordered delivery (use SQS FIFO) or millions of records/second (use Kinesis). |
| Kinesis | High-volume ingestion (clickstreams, IoT, logs), multiple independent consumers, replay needed. | You need exactly-once or transactional semantics (use SQS FIFO) or low-volume ad-hoc routing (use EventBridge). |
| Step Functions | You're orchestrating a multi-step workflow with retries, branches, parallel fan-out, human approvals. | You're moving messages between two services - that's SQS/SNS/EventBridge, not workflow. |
A team's worker fleet uses the SQS SDK defaults - which means short polling (the receive-message call returns immediately, even if the queue is empty). The receive loop runs as fast as it can, around 100 requests/second per worker. The fleet is 50 workers across 4 services. SQS API calls are billed at $0.40 per million. What does the monthly bill look like, and how do you fix it?
100 req/sec × 86,400 sec/day × 30 days = 259 million calls per worker per month.
259M × $0.40 / 1M = ~$103/month per worker.
50 workers × $103 = ~$5,150/month - on a service that's supposed to be "pennies".
Fix: set --wait-time-seconds 20 (or the SDK's WaitTimeSeconds=20) on every receive-message call. This enables long polling - SQS holds the request open for up to 20 seconds waiting for a message, returning early as soon as one arrives. API call count drops 10-20x while message-arrival latency stays sub-second. Bill drops to ~$300/month for the same workload.
Long polling is universally better than short polling - there is essentially no case where short polling is preferable. AWS docs even warn about this. The default is "short" only because changing it would break existing apps that rely on the immediate return.
Goal: get a feel for the AWS messaging surface area in your account. None of these commands cost anything - they're list calls. If your account is new, all four will return empty arrays, which is itself useful (you've now seen the verbs and the responses).
Step 1. List SQS queues. The URL includes your account ID and region - it doubles as an identity check.
aws sqs list-queues --profile compass
# {
# "QueueUrls": [
# "https://sqs.us-east-1.amazonaws.com/123456789012/some-queue"
# ]
# }
# Bonus: only queues whose name starts with "compass"
aws sqs list-queues --queue-name-prefix compass- --profile compass
Step 2. List SNS topics. Each topic has an ARN; subscriptions hang off that ARN.
aws sns list-topics --profile compass
# For each topic, show subscriptions:
aws sns list-subscriptions --profile compass
Step 3. List EventBridge buses. You always have one named default - that's the bus all AWS service events flow into.
aws events list-event-buses --profile compass
# Always at least the "default" bus.
# What rules are on the default bus?
aws events list-rules --event-bus-name default --profile compass
# What targets does a rule have?
aws events list-targets-by-rule --rule <rule-name> --profile compass
Step 4. List Kinesis Data Streams.
aws kinesis list-streams --profile compass
# Describe a stream's shards and retention:
aws kinesis describe-stream-summary --stream-name <name> --profile compass
What you learned: the four messaging services have four totally different CLI namespaces (sqs, sns, events, kinesis) reflecting their different histories. There's no unified "messaging" command - that fragmentation is itself the lesson. Pick the right service up front; switching later means rewriting the producer and consumer.
The numbers in this chapter matter. Sit with each answer before revealing.
1. What is the default visibility timeout for a newly-created SQS queue?
0 seconds (no hiding - any consumer can race)30 seconds5 minutes12 hours (the maximum)2. SQS FIFO queues in their default mode have what throughput cap without batching?
FifoThroughputLimit=perMessageGroupId and DeduplicationScope=messageGroup. Standard queues, by contrast, are advertised as "nearly unlimited".
3. You have an event source: "an S3 object lands in a specific bucket prefix; if its size is over 100 MB AND its content-type is video/mp4, kick off a Step Functions workflow." Which service routes this best?
{"source":["aws.s3"], "detail-type":["Object Created"], "detail":{"bucket":{"name":["my-bucket"]}, "object":{"key":[{"prefix":"videos/"}], "size":[{"numeric":[">",104857600]}]}}}.
4. What is the maximum retention period for a Kinesis Data Stream?
5. You configure a Lambda's SQS event source mapping with BatchSize=100 and MaximumBatchingWindowInSeconds=10. Right now the queue has 27 messages and they're arriving at 1/sec. When will the function fire?
MessageDeduplicationId within 5 minutes, the second is silently dropped. After 5 minutes, the same dedup ID is treated as a new message. This trips up Azure devs who used Service Bus's per-queue dedup-window setting (configurable up to 7 days).
ProvisionedThroughputExceededException. There's no auto-scale; you either reshard manually (split shards) or use Kinesis On-Demand mode (more expensive, scales automatically). Event Hubs has a similar Throughput Unit model, but Azure auto-inflates by default in Standard tier - AWS does not unless you opt into on-demand.
receive-message without --wait-time-seconds uses short polling, which returns immediately even if no messages exist. At scale this can cost thousands of dollars per month in API calls for zero benefit (see the cost-trap above). Always set WaitTimeSeconds=20 in production code. Azure Service Bus doesn't have this distinction - its receive call is always long-poll-style.
The Compass gfn-reports service has grown across the last six chapters: an IAM role (ch2), a VPC (ch3), a Lambda (ch4), an S3 bucket for reports (ch5), and a DynamoDB table for the report index (ch6). Now we wire up the intake side: a Standard SQS queue that the upstream report producers will write to, plus a DLQ for poison messages.
gfn-reports-intake queue + DLQ
Why now: chapter 4's Lambda needs an event source. We're using a Standard queue (no ordering required - reports are independent units of work). Visibility timeout is set to 60s, double the Lambda's 30s timeout, so we never re-deliver a message that's still being processed.
aws sqs create-queue \
--queue-name gfn-reports-intake-dlq \
--attributes '{
"MessageRetentionPeriod": "1209600"
}' \
--tags Project=Compass,ManagedBy=learn-aws \
--profile compass
# Capture the DLQ ARN for the next step
DLQ_URL=$(aws sqs get-queue-url --queue-name gfn-reports-intake-dlq \
--profile compass --query 'QueueUrl' --output text)
DLQ_ARN=$(aws sqs get-queue-attributes --queue-url "$DLQ_URL" \
--attribute-names QueueArn --profile compass \
--query 'Attributes.QueueArn' --output text)
echo "DLQ ARN: $DLQ_ARN" # arn:aws:sqs:us-east-1:123:gfn-reports-intake-dlq
aws sqs create-queue \
--queue-name gfn-reports-intake \
--attributes '{
"VisibilityTimeout": "60",
"MessageRetentionPeriod": "345600",
"ReceiveMessageWaitTimeSeconds": "20",
"RedrivePolicy": "{\"deadLetterTargetArn\":\"'"$DLQ_ARN"'\",\"maxReceiveCount\":\"5\"}"
}' \
--tags Project=Compass,ManagedBy=learn-aws \
--profile compass
# Settings explained:
# VisibilityTimeout=60 - 2x our Lambda timeout (chapter 4 set Lambda to 30s)
# MessageRetentionPeriod=4d - default is 4 days; we leave it alone
# ReceiveMessageWaitTimeSeconds=20 - enables long polling by default (no short-poll cost trap)
# maxReceiveCount=5 - 5 retries before a message is parked in the DLQ
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowSendToIntake",
"Effect": "Allow",
"Action": ["sqs:SendMessage", "sqs:GetQueueAttributes"],
"Resource": "arn:aws:sqs:us-east-1:123456789012:gfn-reports-intake"
},
{
"Sid": "AllowReceiveFromIntake",
"Effect": "Allow",
"Action": [
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:GetQueueAttributes",
"sqs:ChangeMessageVisibility"
],
"Resource": "arn:aws:sqs:us-east-1:123456789012:gfn-reports-intake"
}
]
}
aws iam put-role-policy \
--role-name gfn-reports-role \
--policy-name gfn-reports-intake-access \
--policy-document file://policies/sqs-access-for-reports-role.json \
--profile compass
# Verify both queues are wired correctly
aws sqs list-queues --queue-name-prefix gfn-reports- --profile compass
aws sqs get-queue-attributes \
--queue-url "$(aws sqs get-queue-url --queue-name gfn-reports-intake --profile compass --query QueueUrl --output text)" \
--attribute-names All \
--profile compass | jq '.Attributes | {VisibilityTimeout, RedrivePolicy, ReceiveMessageWaitTimeSeconds}'
We now have an intake queue, a DLQ, long polling enabled by default at the queue level (no caller has to remember to ask), and the gfn-reports-role from chapter 2 has both send and receive permissions. In chapter 8 we'll add KMS encryption to the queue; in chapter 11 we'll wire it to API Gateway as the producer.
| Trap | Fix |
|---|---|
| SQS consumer never deletes messages | Always call delete-message after success. Watch DLQ depth to catch this fast. |
| Visibility timeout shorter than processing time | Bump to 2-3x your function's maximum runtime. Or call change-message-visibility mid-processing. |
| Short polling burning API calls | Set ReceiveMessageWaitTimeSeconds=20 at the queue level (works for all callers). |
| Wrong service: using SNS for routing | If you're inspecting the message body to decide where it goes, you wanted EventBridge. |
| Kinesis hot shard | Choose a partition key that spreads load. Reshard if a single key dominates. |