AWS SQS: Stop Losing Messages (And Your Mind) With Dead Letter Queues ⚡
AWS SQS: Stop Losing Messages (And Your Mind) With Dead Letter Queues ⚡
Real talk: I once lost 2,400 order messages in production. Not hacked. Not a DDoS. Not even a bug I could point to and blame.
Just... gone. Swallowed by SQS like a black hole that doesn't bother sending error notifications.
My boss called it a "learning opportunity." I called it the worst Monday of my career. 😭
Three months and one very humbling post-mortem later, I've architected bulletproof message queues on AWS. Let me save you from my pain!
What Even IS SQS? 🤔
Amazon SQS (Simple Queue Service) is basically a post office for your microservices. When Service A needs to talk to Service B but doesn't want to wait for a reply - and definitely doesn't want things to blow up if Service B is having a bad day - you put a queue in the middle.
Without SQS:
User places order
→ Order Service calls Payment Service directly
→ Payment Service is down 🔥
→ ORDER LOST FOREVER 💸
With SQS:
User places order
→ Order Service puts message in queue
→ Message sits safely in SQS (up to 14 days!)
→ Payment Service picks it up when ready
→ ORDER NEVER LOST ✅
Think of it as a shock absorber. When things get crazy busy, SQS absorbs the spike while your downstream services catch their breath!
The Message That Disappeared 👻
Here's exactly what killed me in production. I had a Lambda consuming SQS messages for order processing:
// Seems fine, right? WRONG!
exports.handler = async (event) => {
for (const record of event.Records) {
const order = JSON.parse(record.body);
await processOrder(order); // What if this fails?
// Lambda auto-deletes messages after success...but what about failures?
}
};
My processOrder() function had a subtle bug for a specific payment gateway. Those messages failed on every attempt, got retried the default number of times, then vanished.
No alert. No email. No CloudWatch metric. Nothing.
SQS had no DLQ configured, so after max retries, messages went into the digital void. 2,400 orders. Gone. 😱
The lesson: Never trust that something will "just work" without a failure destination configured!
Dead Letter Queues: Your Safety Net 🥅
A Dead Letter Queue (DLQ) is where messages go when they've been delivered too many times and still failed. Think of it as the "Problem Children" room.
Setup (AWS CLI):
# Step 1: Create the DLQ first
aws sqs create-queue --queue-name orders-dlq
# Step 2: Get the DLQ ARN
DLQ_ARN=$(aws sqs get-queue-attributes \
--queue-url https://sqs.us-east-1.amazonaws.com/account/orders-dlq \
--attribute-names QueueArn \
--query 'Attributes.QueueArn' \
--output text)
# Step 3: Create main queue WITH DLQ attached (maxReceiveCount=3 means 3 retries)
aws sqs create-queue \
--queue-name orders-main \
--attributes '{
"RedrivePolicy": "{\"deadLetterTargetArn\":\"'$DLQ_ARN'\",\"maxReceiveCount\":\"3\"}"
}'
What happens now:
Message fails → Retry #1
Message fails → Retry #2
Message fails → Retry #3
Message moves to DLQ ← YOU CAN INVESTIGATE IT HERE! 🔍
No more silent losses. Messages wait in the DLQ for 14 days while you figure out what went wrong.
A serverless pattern that saved us: Alert when the DLQ has ANY messages. Zero tolerance means you catch bugs fast!
# CloudWatch alarm - fires when even ONE message hits the DLQ
aws cloudwatch put-metric-alarm \
--alarm-name "OrdersDLQ-HasMessages" \
--metric-name NumberOfMessagesVisible \
--namespace AWS/SQS \
--dimensions Name=QueueName,Value=orders-dlq \
--threshold 1 \
--comparison-operator GreaterThanOrEqualToThreshold \
--evaluation-periods 1 \
--alarm-actions arn:aws:sns:us-east-1:account:alert-team
Now when a payment gateway breaks, I get paged within minutes instead of discovering it during the quarterly audit! 📱
The Visibility Timeout Trap ⏰
This one bit me hard. When SQS delivers a message to a consumer, it becomes "invisible" to other consumers for a set period - the visibility timeout. The default is 30 seconds.
My Lambda was processing large orders that took 3 minutes. You can see where this is going:
t=0:00 Lambda #1 receives order message
t=0:30 VISIBILITY TIMEOUT EXPIRES! Message becomes visible again
t=0:30 Lambda #2 also receives SAME message
t=1:00 Lambda #1 finishes → order processed, card charged
t=1:00 Lambda #2 finishes → SAME order processed, card charged AGAIN
Customer charged twice.
Customer furious.
Me furious.
Everyone furious. 💢
The fix - set visibility timeout to at least 6x your average processing time:
aws sqs set-queue-attributes \
--queue-url https://sqs.us-east-1.amazonaws.com/account/orders-main \
--attributes VisibilityTimeout=360 # 6 minutes for 3-minute jobs
Or extend it dynamically inside your Lambda:
const sqs = new AWS.SQS();
exports.handler = async (event) => {
for (const record of event.Records) {
// Heartbeat: extend visibility before processing long jobs
await sqs.changeMessageVisibility({
QueueUrl: process.env.QUEUE_URL,
ReceiptHandle: record.receiptHandle,
VisibilityTimeout: 300 // 5 more minutes!
}).promise();
await processLargeOrder(JSON.parse(record.body));
}
};
When architecting on AWS, I learned: Visibility timeout is one of those settings that looks harmless at 30 seconds until your checkout flow starts double-charging customers at scale. 🎯
Standard vs FIFO: Pick the Right Tool 🔧
Standard Queue:
- ✅ Basically unlimited throughput
- ✅ Cheapest option
- ⚠️ Messages CAN arrive out of order
- ⚠️ Messages CAN be delivered more than once (at-least-once delivery)
FIFO Queue:
- ✅ Guaranteed order (First In, First Out)
- ✅ Exactly-once processing
- ⚠️ 3,000 messages/second max (with batching)
- ⚠️ Costs ~25% more
My e-commerce rules:
Email notifications → Standard (order doesn't matter, duplicates are fine)
Payment processing → FIFO (exact order matters, no double-charges!)
Inventory updates → FIFO (can't decrement stock twice!)
Analytics events → Standard (who cares if a stat is slightly delayed?)
Real FIFO gotcha: You MUST include MessageGroupId and MessageDeduplicationId or the API will reject your message:
await sqs.sendMessage({
QueueUrl: process.env.FIFO_QUEUE_URL, // Must end in .fifo!
MessageBody: JSON.stringify(order),
MessageGroupId: `customer-${order.customerId}`, // Same customer = same group
MessageDeduplicationId: `order-${order.id}` // Prevent duplicates
}).promise();
Forget these and you get a hard API error. At least it fails loudly! 💥
Long Polling: Stop Wasting Money on Empty Checks 💰
Short polling (default behavior, expensive):
Consumer: "Any messages?"
SQS: "Nope" [charges you $0.0000004]
Consumer: "Any messages?"
SQS: "Nope" [charges you again]
Consumer: "Any messages?"
SQS: "Nope" [charges you again]
...repeat 1000x per minute = $0.02/hour = $15/month JUST FOR EMPTY CHECKS 🤦
Long polling (use this always):
Consumer: "Any messages? I'll wait up to 20 seconds"
SQS: "...............here are 10 messages!" [one charge, one round trip]
Enable it:
await sqs.receiveMessage({
QueueUrl: process.env.QUEUE_URL,
WaitTimeSeconds: 20, // Wait up to 20 seconds before returning empty
MaxNumberOfMessages: 10 // Grab up to 10 at once
}).promise();
Or set it permanently on the queue:
aws sqs set-queue-attributes \
--queue-url https://sqs.us-east-1.amazonaws.com/account/orders-main \
--attributes ReceiveMessageWaitTimeSeconds=20
In production, I've deployed queues that went from 864,000 API calls/day to 43,200/day just by enabling long polling. That's 95% fewer API calls for literally one config change! 💸
Batch Processing: The Throughput Multiplier 🚀
Don't process one message at a time like a rookie. Process in batches - and do it correctly:
exports.handler = async (event) => {
// Lambda SQS trigger: event.Records has up to 10 messages
const orders = event.Records.map(record => ({
body: JSON.parse(record.body),
messageId: record.messageId
}));
// Process ALL orders concurrently
const results = await Promise.allSettled(
orders.map(order => processOrder(order.body))
);
// Report PARTIAL failures - only retry the ones that actually failed
const batchItemFailures = results
.map((result, index) => ({ result, order: orders[index] }))
.filter(({ result }) => result.status === 'rejected')
.map(({ order }) => ({ itemIdentifier: order.messageId }));
// Return failed message IDs - SQS retries only those!
return { batchItemFailures };
};
Why batchItemFailures matters: Without it, if 1 message in a batch of 10 fails, all 10 get retried. With it, only the 1 bad message gets retried.
A serverless pattern that saved us: Reporting partial failures correctly cut our unnecessary retries by ~60% during high-error periods. Your DLQ depth will also be much more accurate! 🎯
The Real Cost Breakdown 💸
SQS Pricing (as of 2026):
- First 1 million requests/month: FREE 🎁
- Standard: $0.40 per million requests after that
- FIFO: $0.50 per million requests
My actual production costs:
E-commerce backend (medium traffic):
- Messages sent: 2M/month → $0.80
- Messages received: 6M/month → $2.40 (read 3x per message!)
- Messages deleted: 2M/month → $0.80
- Total: ~$4/month 🎉
WITHOUT long polling: ~$80/month (empty poll overhead)
WITH long polling: ~$4/month
Savings: $76/month from one config flag. Buy yourself a nice dinner! 🍕
Cost gotchas to watch:
- Send, receive, and delete are separate API calls (each billed separately!)
- Batching saves big: send 10 messages = 1 API call, not 10
- Messages over 64KB: extra charge per 64KB chunk (use S3 for big payloads!)
Common Mistakes I Made (So You Don't Have To) 🪤
Mistake #1: No Idempotency
SQS will deliver your message at least once. Possibly twice. Possibly three times. Make your consumers idempotent or you will double-charge customers:
// BAD - Can charge customer twice!
async function processOrder(order) {
await chargeCard(order.paymentId, order.amount);
await updateInventory(order.items);
await sendConfirmationEmail(order.email);
}
// GOOD - Check if already processed
async function processOrder(order) {
const alreadyProcessed = await db.exists('processed_orders', order.id);
if (alreadyProcessed) {
console.log(`Order ${order.id} already processed, skipping!`);
return; // SQS will delete the message - all good!
}
await db.insert('processed_orders', { id: order.id, processedAt: new Date() });
await chargeCard(order.paymentId, order.amount);
await updateInventory(order.items);
await sendConfirmationEmail(order.email);
}
In production, I've deployed idempotency checks on every single SQS consumer. No exceptions. No "we'll add it later." Now. 🔒
Mistake #2: Not Monitoring Queue Depth
# Add this CloudWatch alarm to catch runaway queues
aws cloudwatch put-metric-alarm \
--alarm-name "OrdersQueue-Backlog" \
--metric-name ApproximateNumberOfMessagesVisible \
--namespace AWS/SQS \
--dimensions Name=QueueName,Value=orders-main \
--threshold 1000 \
--comparison-operator GreaterThanOrEqualToThreshold \
--evaluation-periods 3 \
--alarm-actions arn:aws:sns:us-east-1:account:alert-team
Rule of thumb: If queue depth grows for 10+ minutes straight, your consumers are down or too slow.
Mistake #3: Message Size Bloat
Shoving entire database records into messages is a trap:
// BAD - 150KB message, slow and expensive
await sqs.sendMessage({
MessageBody: JSON.stringify(entireOrderWithAllLineItemsAndCustomerHistory)
}).promise();
// GOOD - Tiny message, fetch details when needed
await sqs.sendMessage({
MessageBody: JSON.stringify({ orderId: 'ord_123', type: 'NEW_ORDER' })
}).promise();
// Consumer fetches what it needs
exports.handler = async (event) => {
const { orderId } = JSON.parse(event.Records[0].body);
const order = await db.findById(orderId); // Fresh data from DB
await processOrder(order);
};
Keep messages small. Pass IDs, not full payloads. Your wallet will thank you! 💰
The Production Architecture That Actually Works 🏗️
In production, I've deployed this pattern across the entire e-commerce backend:
Order Service (Producer)
↓
orders-main
(Standard SQS, 6 min visibility timeout, 20s long polling, max 10/batch)
↓ (after 3 failures)
orders-dlq
(14-day retention, CloudWatch alarm at depth=1)
↓
Payment Lambda (Consumer, batch size 10, idempotent, partial failure reporting)
↓
Success → message auto-deleted ✅
Failure → DLQ → CloudWatch Alarm → PagerDuty → Me at 3 AM 📱
This handles:
- 50,000 orders/day without breaking a sweat
- Zero lost messages (since adding DLQ)
- Payment gateway outages without data loss
- Total cost: ~$8/month 🎉
TL;DR: SQS Survival Guide 🎯
- Always configure a DLQ - No DLQ = silent message loss. Full stop.
- Set visibility timeout to 6x your average processing time
- Enable long polling (WaitTimeSeconds=20) - saves 90%+ on API costs
- Use FIFO for payments and inventory, Standard for everything else
- Make consumers idempotent - SQS WILL deliver duplicates
- Report partial batch failures - don't waste retries on good messages
- Alert on queue depth - growing queue = broken consumer
SQS is deceptively simple to set up and surprisingly painful to get right. The setup takes 5 minutes. Getting it production-hardened takes 6 months of hard lessons.
Now you have those 6 months in a 5-minute read. Go configure that DLQ before Monday morning! 🚀
Still losing messages? Connect with me on LinkedIn - I've got battle scars and I'm happy to share them!
Want to see the full queue architecture? Check out my GitHub for real-world SQS patterns from production systems!
Now go add those Dead Letter Queues before your next Monday morning incident. ⚡
P.S. The 2,400 lost orders? We recovered them from application logs and reprocessed manually over 8 hours. It was a great team-building experience. 10/10 do not recommend. 😤
P.P.S. SQS is free for your first 1 million requests per month. There is absolutely no excuse to not have a DLQ on every queue. Zero excuses. Set it up! 🎁