AWS SQS Mastery: Standard vs FIFO Queues and Dead Letter Handling for Microservices and Distributed Systems
AWS SQS Mastery: Standard vs FIFO Queues and Dead Letter Handling for Microservices
Introduction
Your Rails application processes orders synchronously. A user clicks “Purchase,” and your server charges the card, updates inventory, sends confirmation emails, and logs analytics—all before responding. One service is slow, the entire request times out. One service fails, the whole transaction fails. Your application is a monolith where every component’s failure cascades.
Amazon Simple Queue Service (SQS) decouples components by introducing asynchronous message passing. Instead of calling services directly, you send messages to queues. Consumers process messages independently, at their own pace. One service fails? Messages wait in the queue. Traffic spikes? Messages buffer until consumers catch up. Your application becomes resilient, scalable, and loosely coupled.
But SQS isn’t just “a queue.” It’s understanding Standard vs FIFO queues, managing visibility timeouts, implementing dead-letter queues for poison messages, configuring long polling, and architecting distributed systems where failures are isolated and recovery is automatic.
In this guide, we’ll master SQS: creating queues, sending and receiving messages, handling failures with DLQs, choosing Standard vs FIFO, and building production microservice architectures.
What Is Amazon SQS?
SQS is a fully managed message queuing service that enables decoupling and scaling of distributed systems.
Key SQS Characteristics
| Feature | Description |
|---|---|
| Fully Managed | No servers, automatic scaling, high availability |
| Unlimited Throughput | Process millions of messages |
| At-Least-Once Delivery | Messages delivered one or more times (Standard) |
| Message Retention | 1 minute to 14 days |
| Two Queue Types | Standard (best-effort ordering) and FIFO (strict ordering) |
Why Message Queues?
✅ Decoupling: Services communicate through queues, not direct calls
✅ Buffering: Handle traffic spikes without overwhelming consumers
✅ Fault Tolerance: Failed messages return to queue for retry
✅ Scalability: Add consumers without changing producers
✅ Async Processing: Improve response times by deferring work
Standard vs FIFO Queues
Standard Queue
Characteristics:
- Unlimited throughput: Process millions of messages/second
- At-least-once delivery: Messages may be delivered multiple times
- Best-effort ordering: Messages may arrive out of order
Use cases:
- Background job processing
- Event broadcasting
- Log aggregation
- Non-critical tasks where order doesn’t matter
Create Standard queue:
1
2
3
aws sqs create-queue \
--queue-name order-processing-queue \
--attributes MessageRetentionPeriod=345600,VisibilityTimeout=30
FIFO Queue
Characteristics:
- Limited throughput: 300 messages/second (3,000 with batching)
- Exactly-once processing: No duplicates
- Strict ordering: Messages processed in exact order sent
- Message groups: Maintain order within groups
Use cases:
- Financial transactions
- Command processing where order matters
- Event sourcing
- Inventory updates
Create FIFO queue:
1
2
3
4
5
6
7
aws sqs create-queue \
--queue-name order-processing.fifo \
--attributes '{
"FifoQueue": "true",
"ContentBasedDeduplication": "true",
"MessageRetentionPeriod": "345600"
}'
Note: FIFO queue names must end with .fifo.
Comparison Table
| Feature | Standard | FIFO |
|---|---|---|
| Throughput | Unlimited | 300 msgs/sec (3,000 with batching) |
| Ordering | Best-effort | Strict FIFO |
| Duplicates | Possible | No duplicates |
| Use Case | High throughput, order doesn’t matter | Strict order required |
| Cost | $0.40 per million requests | $0.50 per million requests |
Sending Messages
Send Single Message (Standard Queue)
1
2
3
aws sqs send-message \
--queue-url https://sqs.us-east-1.amazonaws.com/123456789012/order-processing-queue \
--message-body '{"orderId":123,"userId":456,"total":99.99}'
Ruby example:
1
2
3
4
5
6
7
8
9
10
11
12
require 'aws-sdk-sqs'
sqs = Aws::SQS::Client.new(region: 'us-east-1')
sqs.send_message(
queue_url: 'https://sqs.us-east-1.amazonaws.com/123456789012/order-processing-queue',
message_body: {
order_id: 123,
user_id: 456,
total: 99.99
}.to_json
)
Send Message with Delay
1
2
3
4
aws sqs send-message \
--queue-url https://sqs.us-east-1.amazonaws.com/123456789012/order-processing-queue \
--message-body '{"task":"retry"}' \
--delay-seconds 300 # 5 minutes
Use case: Retry logic, scheduled tasks.
Send Message to FIFO Queue
1
2
3
4
5
aws sqs send-message \
--queue-url https://sqs.us-east-1.amazonaws.com/123456789012/order-processing.fifo \
--message-body '{"orderId":123}' \
--message-group-id "order-123" \
--message-deduplication-id "$(uuidgen)"
Key parameters:
message-group-id: Orders within group are processed sequentiallymessage-deduplication-id: Prevents duplicates (optional if ContentBasedDeduplication enabled)
Send Batch Messages
1
2
3
4
5
6
7
aws sqs send-message-batch \
--queue-url https://sqs.us-east-1.amazonaws.com/123456789012/order-processing-queue \
--entries '[
{"Id":"1","MessageBody":"{\"orderId\":101}"},
{"Id":"2","MessageBody":"{\"orderId\":102}"},
{"Id":"3","MessageBody":"{\"orderId\":103}"}
]'
Benefit: Up to 10 messages per request, reduces API calls.
Receiving Messages
Receive Single Message
1
2
3
4
aws sqs receive-message \
--queue-url https://sqs.us-east-1.amazonaws.com/123456789012/order-processing-queue \
--max-number-of-messages 1 \
--wait-time-seconds 20 # Long polling
Response:
1
2
3
4
5
6
7
8
9
10
11
{
"Messages": [{
"MessageId": "abc-123",
"ReceiptHandle": "xyz-789...",
"Body": "{\"orderId\":123}",
"Attributes": {
"ApproximateReceiveCount": "1",
"SentTimestamp": "1705334400000"
}
}]
}
Ruby Worker
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
require 'aws-sdk-sqs'
require 'json'
sqs = Aws::SQS::Client.new(region: 'us-east-1')
queue_url = 'https://sqs.us-east-1.amazonaws.com/123456789012/order-processing-queue'
loop do
# Long poll for messages (20 seconds)
resp = sqs.receive_message(
queue_url: queue_url,
max_number_of_messages: 10,
wait_time_seconds: 20,
attribute_names: ['All']
)
resp.messages.each do |msg|
begin
# Parse message
data = JSON.parse(msg.body)
# Process order
process_order(data['order_id'])
# Delete message (success)
sqs.delete_message(
queue_url: queue_url,
receipt_handle: msg.receipt_handle
)
puts "Processed order #{data['order_id']}"
rescue => e
puts "Error processing message: #{e.message}"
# Message will return to queue after visibility timeout
end
end
end
Visibility Timeout
Visibility timeout: Duration message is hidden from other consumers after being received.
1
2
3
Message received → Hidden for 30s (visibility timeout)
├─ Consumer processes successfully → Delete message
└─ Consumer fails (or timeout expires) → Message returns to queue
Set per-message:
1
2
3
4
aws sqs change-message-visibility \
--queue-url https://sqs.us-east-1.amazonaws.com/123456789012/order-processing-queue \
--receipt-handle xyz-789... \
--visibility-timeout 300 # Extend to 5 minutes
Use case: Long-running tasks that need more time.
Dead-Letter Queues (DLQ)
Dead-Letter Queue: Separate queue for messages that fail repeatedly.
Why DLQs?
- Isolate poison messages (malformed data, bugs)
- Prevent infinite retry loops
- Debug failures without blocking main queue
Configure DLQ
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Create DLQ
aws sqs create-queue \
--queue-name order-processing-dlq
# Get DLQ ARN
aws sqs get-queue-attributes \
--queue-url https://sqs.us-east-1.amazonaws.com/123456789012/order-processing-dlq \
--attribute-names QueueArn
# Configure main queue to use DLQ
aws sqs set-queue-attributes \
--queue-url https://sqs.us-east-1.amazonaws.com/123456789012/order-processing-queue \
--attributes '{
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:123456789012:order-processing-dlq\",\"maxReceiveCount\":\"3\"}"
}'
Behavior:
- Message received 3 times (maxReceiveCount) without deletion → Moved to DLQ
- Investigate DLQ messages manually or with Lambda
Monitor DLQ
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Check DLQ size
aws sqs get-queue-attributes \
--queue-url https://sqs.us-east-1.amazonaws.com/123456789012/order-processing-dlq \
--attribute-names ApproximateNumberOfMessages
# Set CloudWatch alarm
aws cloudwatch put-metric-alarm \
--alarm-name sqs-dlq-messages \
--metric-name ApproximateNumberOfMessagesVisible \
--namespace AWS/SQS \
--statistic Average \
--period 300 \
--evaluation-periods 1 \
--threshold 10 \
--comparison-operator GreaterThanThreshold \
--dimensions Name=QueueName,Value=order-processing-dlq
Redrive Messages from DLQ
1
2
# Move messages back to main queue after fixing issue
# (Manually via console or with Lambda function)
Long Polling vs Short Polling
Short Polling (Default)
- Returns immediately (even if no messages)
- More API calls
- Higher cost
- May return empty responses
Long Polling
- Waits up to 20 seconds for messages
- Fewer API calls
- Lower cost
- Reduces empty responses
Enable long polling:
1
2
3
4
5
6
7
8
9
# Queue-level
aws sqs set-queue-attributes \
--queue-url https://sqs.us-east-1.amazonaws.com/123456789012/order-processing-queue \
--attributes ReceiveMessageWaitTimeSeconds=20
# Or per-request
aws sqs receive-message \
--queue-url ... \
--wait-time-seconds 20
Recommendation: Always use long polling (20 seconds).
Message Attributes
Add metadata to messages without parsing body.
1
2
3
4
5
6
7
8
aws sqs send-message \
--queue-url https://sqs.us-east-1.amazonaws.com/123456789012/order-processing-queue \
--message-body '{"orderId":123}' \
--message-attributes '{
"Priority": {"StringValue":"High","DataType":"String"},
"Source": {"StringValue":"WebApp","DataType":"String"},
"RetryCount": {"StringValue":"0","DataType":"Number"}
}'
Use cases:
- Filtering messages
- Priority queuing
- Tracking metadata
SQS with Lambda
Lambda polls SQS and invokes function for each message batch.
1
2
3
4
5
6
# Create event source mapping
aws lambda create-event-source-mapping \
--function-name process-orders \
--event-source-arn arn:aws:sqs:us-east-1:123456789012:order-processing-queue \
--batch-size 10 \
--maximum-batching-window-in-seconds 5
Lambda function:
1
2
3
4
5
6
7
8
9
10
def lambda_handler(event, context):
for record in event['Records']:
body = json.loads(record['body'])
order_id = body['orderId']
# Process order
process_order(order_id)
# Lambda automatically deletes messages on success
return {'statusCode': 200}
Benefits:
- Automatic scaling (Lambda scales with queue depth)
- No polling code needed
- Automatic message deletion on success
- Failed messages return to queue
Real-World Architecture: Order Processing System
Requirements
- Decouple order placement from processing
- Handle traffic spikes
- Retry failed orders
- Isolate poison messages
Architecture
1
2
3
4
5
6
7
8
9
10
11
12
13
Web App (Rails)
│
├─→ Place Order API
│ └─→ SQS (orders-queue)
│
└─→ Workers (Auto Scaling Group)
├─ Worker 1 polls orders-queue
├─ Worker 2 polls orders-queue
└─ Worker 3 polls orders-queue
│
├─ Success → Delete message
├─ Failure → Return to queue (retry)
└─ Max retries → DLQ (orders-dlq)
Implementation
1. Create queues:
1
2
3
4
5
6
7
8
9
10
11
12
# DLQ
aws sqs create-queue --queue-name orders-dlq
# Main queue with DLQ
aws sqs create-queue \
--queue-name orders-queue \
--attributes '{
"MessageRetentionPeriod": "345600",
"VisibilityTimeout": "300",
"ReceiveMessageWaitTimeSeconds": "20",
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:123456789012:orders-dlq\",\"maxReceiveCount\":\"3\"}"
}'
2. Rails controller (producer):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class OrdersController < ApplicationController
def create
order = Order.create!(order_params)
# Send to SQS (non-blocking)
SqsService.send_order(order)
render json: { order_id: order.id, status: 'processing' }, status: :accepted
end
end
# app/services/sqs_service.rb
class SqsService
def self.send_order(order)
sqs = Aws::SQS::Client.new(region: 'us-east-1')
sqs.send_message(
queue_url: ENV['ORDERS_QUEUE_URL'],
message_body: {
order_id: order.id,
user_id: order.user_id,
total: order.total
}.to_json
)
end
end
3. Worker (consumer):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# worker.rb
require 'aws-sdk-sqs'
sqs = Aws::SQS::Client.new(region: 'us-east-1')
queue_url = ENV['ORDERS_QUEUE_URL']
loop do
resp = sqs.receive_message(
queue_url: queue_url,
max_number_of_messages: 10,
wait_time_seconds: 20,
attribute_names: ['ApproximateReceiveCount']
)
resp.messages.each do |msg|
begin
data = JSON.parse(msg.body)
# Process order (charge card, update inventory, etc.)
OrderProcessor.process(data['order_id'])
# Delete on success
sqs.delete_message(
queue_url: queue_url,
receipt_handle: msg.receipt_handle
)
Rails.logger.info "Processed order #{data['order_id']}"
rescue => e
retry_count = msg.attributes['ApproximateReceiveCount'].to_i
Rails.logger.error "Error processing order (attempt #{retry_count}): #{e.message}"
# Message returns to queue for retry
end
end
end
Result:
- Decoupled: Web app doesn’t wait for order processing
- Scalable: Add more workers as queue depth increases
- Resilient: Failed orders retry automatically (up to 3 times)
- Observable: Monitor DLQ for persistent failures
Monitoring and Metrics
Key CloudWatch Metrics
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Number of visible messages
aws cloudwatch get-metric-statistics \
--namespace AWS/SQS \
--metric-name ApproximateNumberOfMessagesVisible \
--dimensions Name=QueueName,Value=orders-queue \
--start-time 2024-01-15T00:00:00Z \
--end-time 2024-01-15T23:59:59Z \
--period 300 \
--statistics Average
# Age of oldest message
aws cloudwatch get-metric-statistics \
--metric-name ApproximateAgeOfOldestMessage \
...
# Messages in flight (being processed)
aws cloudwatch get-metric-statistics \
--metric-name ApproximateNumberOfMessagesNotVisible \
...
Critical metrics:
ApproximateNumberOfMessagesVisible: Queue backlogApproximateAgeOfOldestMessage: Processing lagNumberOfMessagesSent: ThroughputNumberOfMessagesDeleted: Successful processing rate
Security Best Practices
1. Use IAM Policies
1
2
3
4
5
6
7
8
9
10
11
12
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"sqs:SendMessage",
"sqs:ReceiveMessage",
"sqs:DeleteMessage"
],
"Resource": "arn:aws:sqs:us-east-1:123456789012:orders-queue"
}]
}
2. Server-Side Encryption
1
2
3
4
5
6
aws sqs set-queue-attributes \
--queue-url https://sqs.us-east-1.amazonaws.com/123456789012/orders-queue \
--attributes '{
"KmsMasterKeyId": "alias/aws/sqs",
"KmsDataKeyReusePeriodSeconds": "300"
}'
3. Queue Access Policy
1
2
3
4
5
6
7
8
9
10
11
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/OrderProcessorRole"
},
"Action": "sqs:*",
"Resource": "arn:aws:sqs:us-east-1:123456789012:orders-queue"
}]
}
SQS Pricing
| Component | Standard Queue | FIFO Queue |
|---|---|---|
| First 1M requests/month | Free | Free |
| Requests | $0.40 per million | $0.50 per million |
| Data transfer | Standard AWS rates | Standard AWS rates |
Example: 10 million messages/month
- Standard: (10M - 1M) × $0.40 / 1M = $3.60/month
- FIFO: (10M - 1M) × $0.50 / 1M = $4.50/month
Extremely cost-effective.
Best Practices
1. Use Batching
1
2
3
4
5
6
7
8
9
10
# Bad: 100 API calls
100.times do |i|
sqs.send_message(queue_url: url, message_body: "msg#{i}")
end
# Good: 10 API calls (10 messages per batch)
messages = 100.times.map { |i| { id: i.to_s, message_body: "msg#{i}" } }
messages.each_slice(10) do |batch|
sqs.send_message_batch(queue_url: url, entries: batch)
end
2. Implement Idempotency
1
2
3
4
5
6
7
8
9
10
11
12
def process_order(order_id)
# Check if already processed (database flag)
return if Order.find(order_id).processed?
# Process
charge_payment(order_id)
update_inventory(order_id)
send_email(order_id)
# Mark as processed
Order.find(order_id).update(processed: true)
end
3. Set Appropriate Visibility Timeout
- Too short: Message returns before processing completes
- Too long: Failed messages take long to retry
Rule of thumb: 6x average processing time.
4. Monitor DLQs
Set up CloudWatch alarms for DLQ depth:
1
2
3
4
5
6
7
aws cloudwatch put-metric-alarm \
--alarm-name orders-dlq-alarm \
--metric-name ApproximateNumberOfMessagesVisible \
--namespace AWS/SQS \
--dimensions Name=QueueName,Value=orders-dlq \
--threshold 5 \
--comparison-operator GreaterThanThreshold
Conclusion
AWS SQS transforms tightly-coupled synchronous systems into loosely-coupled asynchronous architectures. By mastering Standard vs FIFO queues, dead-letter handling, and message lifecycle management, you architect systems that scale elastically, recover automatically from failures, and isolate component failures.
The shift from direct service calls to message passing, from synchronous blocking to asynchronous buffering, and from cascading failures to isolated retries transforms applications from fragile to resilient.
Start simple: create a queue, send messages, poll and process. Then evolve: add DLQs for failure handling, integrate with Lambda for serverless processing, implement FIFO for strict ordering. Every iteration makes your system more decoupled and more robust.
Master SQS, and you master distributed messaging.
Suggested Reading
- AWS SQS Official Documentation
- SQS Developer Guide
- Standard vs FIFO Queues
- Dead-Letter Queues
- SQS Best Practices
- Message Lifecycle