Building an asynchronous messaging system with SQS and SNS: creating queues and topics, sending and receiving messages, dead-letter queues, visibility timeout, and fan-out patterns between services.

In episode 7 we brought Lambda to life as an event-driven compute unit. But in the real world, services don't call each other directly and wait for the answer — they talk through an intermediary called a message broker. AWS provides two services for this: SQS (a queue, one consumer per message) and SNS (a topic, one message broadcast to many subscribers). You could say: SQS is a bank queue, SNS is a mosque loudspeaker.
In this episode you'll create queues and topics, play with visibility timeout and dead-letter queues, then assemble a fan-out pattern connecting SQS, SNS, and Lambda in a single flow.
SQS is the perfect candidate for decoupling producer and consumer: the producer just drops a message, and the consumer processes it at its own pace. Let's start by creating a queue:
awslocal sqs create-queue --queue-name orders
awslocal sqs send-message \
--queue-url http://localhost:4566/000000000000/orders \
--message-body '{"orderId": "ORD-001"}'
awslocal sqs receive-message \
--queue-url http://localhost:4566/000000000000/ordersNotice two important things:
awslocal sqs delete-message with the --receipt-handle from the receive result. If you don't delete it, the message will appear again.http://localhost:4566/<account>/<queue-name>. This is the same address production code uses when accessing LocalStack.When a consumer receives a message, that message becomes invisible to other consumers for a period called the visibility timeout. This prevents two workers from processing the same message simultaneously. The default is 30 seconds:
awslocal sqs create-queue --queue-name slow-orders \
--attributes VisibilityTimeout=120If a worker fails and doesn't delete the message before the timeout expires, the message becomes visible again and can be retried by another worker. This pattern gives at-least-once semantics: it guarantees no message is lost, but some may be processed twice — so consumer idempotency is a requirement, not a choice.
Imagine a corrupted message that always fails to process. Without a safety net, it would pollute the queue forever and block the line. The solution: a dead-letter queue (DLQ), a trash bin for messages that have been tried many times but still failed. Configure it via the RedrivePolicy attribute:
awslocal sqs create-queue --queue-name orders-dlq
awslocal sqs create-queue --queue-name orders \
--attributes '{"RedrivePolicy":"{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:orders-dlq\",\"maxReceiveCount\":3}"}'
awslocal sqs get-queue-attributes --queue-url http://localhost:4566/000000000000/orders \
--attribute-names AllAfter three receives without a delete, the message is automatically moved to orders-dlq. This is where you can debug at your leisure: take the message from the DLQ, fix the consumer, then redrive it back.
Warning
The DLQ must be created before the main queue references it in RedrivePolicy. Referencing an ARN that doesn't exist yet causes queue creation to fail.
SQS is a one-to-one queue. SNS is a one-to-many pattern: one publish spreads to every subscriber. Create a topic, register an SQS queue as a subscriber, then publish:
awslocal sns create-topic --name order-events
awslocal sns subscribe --topic-arn arn:aws:sns:us-east-1:000000000000:order-events \
--protocol sqs \
--notification-endpoint arn:aws:sqs:us-east-1:000000000000:orders
awslocal sns publish --topic-arn arn:aws:sns:us-east-1:000000000000:order-events \
--message '{"status": "paid"}'
awslocal sqs receive-message --queue-url http://localhost:4566/000000000000/ordersThis pattern is called fan-out: a single "order paid" event can simultaneously trigger record-keeping, email delivery, and inventory updates. Subscriptions can be SQS, Lambda, HTTP, or email (mocked). For email, the email protocol only writes the message to the emulator log — check it with localstack logs. For Lambda, point --notification-endpoint at the function ARN:
awslocal lambda create-function --function-name order-handler \
--runtime python3.12 --role arn:aws:iam::000000000000:role/lambda-role \
--handler handler.handler --zip-file fileb://function.zip
awslocal sns subscribe --topic-arn arn:aws:sns:us-east-1:000000000000:order-events \
--protocol lambda --notification-endpoint arn:aws:lambda:us-east-1:000000000000:function:order-handlerWhen a queue needs to be processed automatically by Lambda, we use an event source mapping like in episode 7. LocalStack pulls messages from the queue and invokes the function in batches:
awslocal lambda create-event-source-mapping \
--function-name order-handler \
--event-source-arn arn:aws:sqs:us-east-1:000000000000:orders \
--batch-size 10This combination is the most common working pattern in production: SNS broadcasts the event → SQS absorbs the load → Lambda processes it batch by batch. LocalStack fully supports this chain locally.
Choosing the right queue type matters. SQS has two types: Standard and FIFO:
| Aspect | Standard Queue | FIFO Queue |
|---|---|---|
| Ordering | Best-effort, not guaranteed | Strict, per message group |
| Delivery | At-least-once (may duplicate) | Exactly-once with dedup |
| Throughput | Very high | Limited, capped per group |
| Latency | Low | Slightly higher |
| Use cases | Logs, metrics, notifications | Financial transactions, event ordering |
For guaranteed ordering, use FIFO. Its name must end in .fifo and every publish must include --message-group-id:
awslocal sqs create-queue --queue-name orders.fifo \
--attributes FifoQueue=true,ContentBasedDeduplication=true
awslocal sqs send-message \
--queue-url http://localhost:4566/000000000000/orders.fifo \
--message-body '{"event": "created"}' --message-group-id g1Tip
Don't blindly pick FIFO for everything. FIFO limits throughput and adds complexity. Standard is faster and cheaper — use FIFO only when ordering or duplication truly can't be tolerated.
Summary of this episode:
create-queue, send-message, receive-message, then delete-message with a receipt handle.maxReceiveCount in RedrivePolicy.Messages now flow between your services. But who serves those messages to the outside world? In episode 9 we open the front door: API Gateway — REST and HTTP APIs, Lambda proxy integration, stages, custom domains, and API key and JWT authorizers. See you there!