Post

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 sequentially
  • message-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 backlog
  • ApproximateAgeOfOldestMessage: Processing lag
  • NumberOfMessagesSent: Throughput
  • NumberOfMessagesDeleted: 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

This post is licensed under CC BY 4.0 by the author.