AWS · lesson 14 of 19
Beginner
4Intermediate
13- 5 VPC and Networking Basics
- 6 RDS Basics
- 7 AWS Lambda Fundamentals
- 8 CloudFormation Basics
- 9 CloudWatch Monitoring Basics
- 11 DynamoDB: Tables and Keys
- 12 DynamoDB: Scan, Reserved Words, and Global Secondary Indexes
- 13 DynamoDB: Conditional Writes, Atomic Counters, and Streams
- 14 SQS: Visibility Timeout and Dead-Letter Queues
- 15 SQS: FIFO Queues, Message Groups, and Deduplication
- 16 SNS: Fan-Out to SQS and Filter Policies
- 17 ELB and Auto Scaling: Wiring an ASG Behind a Load Balancer
- 18 ECS: Task Definitions and Services
Also part of AWS
8- + Linting AWS IAM Policies Before Deploy with Parliament from Cloud Security
- + Auditing the AWS IAM Credential Report with Python from Cloud Security
- + Hunting in AWS CloudTrail Logs with Python from Cloud Security
- + Dead-Letter Queues: What Happens When Retries Keep Failing from Event-Driven Architecture
- + Idempotent Consumers: Surviving a Duplicate Delivery from Event-Driven Architecture
- + Load Balancing: Round Robin vs Least Connections from System Design
- + Kubernetes Fundamentals from Kubernetes
- + Horizontal Pod Autoscaling: Real Replicas, Real CPU Numbers from Kubernetes
SQS: Visibility Timeout and Dead-Letter Queues
Send one SQS message, watch its ApproximateReceiveCount climb across a 2-second visibility timeout, and see it redrive into a dead-letter queue the moment maxReceiveCount is exceeded — against the moto emulator, no AWS account.
By SkillByExample · Updated
SQS’s visibility timeout hides a received message from other consumers for a window, betting the current consumer will finish and delete it. A dead-letter queue (DLQ) catches a message that blows through that window too many times — this lesson triggers both for real, against moto, with no AWS account.
Creating a Queue With a Redrive Policy
The DLQ is just another queue; what makes it a DLQ is orders’ own RedrivePolicy naming it.
#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws14-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@" --endpoint-url http://aws14-moto:5000; }
DLQ_URL=$(aws sqs create-queue --queue-name orders-dlq --query 'QueueUrl' --output text)
DLQ_ARN=$(aws sqs get-queue-attributes --queue-url "$DLQ_URL" --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)
echo "dlq arn: $DLQ_ARN"
REDRIVE="{\"deadLetterTargetArn\":\"$DLQ_ARN\",\"maxReceiveCount\":\"2\"}"
Q_URL=$(aws sqs create-queue --queue-name orders \
--attributes "{\"VisibilityTimeout\":\"2\",\"RedrivePolicy\":\"$(echo "$REDRIVE" | sed 's/"/\\"/g')\"}" \
--query 'QueueUrl' --output text)
echo "queue url: $Q_URL"
aws sqs get-queue-attributes --queue-url "$Q_URL" --attribute-names VisibilityTimeout RedrivePolicy
$ docker network create aws14-lab
2342ff6a344fd031d38fe7a9c58f10d86bb8b7125fd692e90788d2907fb3e6c8
$ docker run -d --name aws14-moto --network aws14-lab motoserver/moto:5.2.5
15b589036313c0ba2f1c19ffafe954fa8c23c6207ce79c7f5528da85e1f44079
$ until docker run --rm --network aws14-lab curlimages/curl:8.11.1 -s -o /dev/null http://aws14-moto:5000; do sleep 0.3; done
$ bash setup.sh
dlq arn: arn:aws:sqs:us-east-1:123456789012:orders-dlq
queue url: http://aws14-moto:5000/123456789012/orders
{
"Attributes": {
"VisibilityTimeout": "2",
"RedrivePolicy": "{\"deadLetterTargetArn\": \"arn:aws:sqs:us-east-1:123456789012:orders-dlq\", \"maxReceiveCount\": 2}"
}
}
Both queue URLs are http://aws14-moto:5000/... — moto, not a real region’s SQS endpoint — but the
RedrivePolicy it stores back is the exact JSON shape a real queue uses: a target ARN plus a
receive-count threshold.
Sending One Message and Watching ApproximateReceiveCount Climb
Two receives, each separated by a sleep past the 2-second visibility timeout, with no
DeleteMessage in between — standing in for a consumer that keeps failing to finish in time.
#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws14-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@" --endpoint-url http://aws14-moto:5000; }
Q_URL="http://aws14-moto:5000/123456789012/orders"
aws sqs send-message --queue-url "$Q_URL" --message-body '{"orderId":"o-900"}' >/dev/null
echo "receive 1:"
aws sqs receive-message --queue-url "$Q_URL" --attribute-names ApproximateReceiveCount \
--query 'Messages[0].Attributes.ApproximateReceiveCount' --output text
sleep 3
echo "receive 2:"
aws sqs receive-message --queue-url "$Q_URL" --attribute-names ApproximateReceiveCount \
--query 'Messages[0].Attributes.ApproximateReceiveCount' --output text
$ bash receive_cycle.sh
receive 1:
1
receive 2:
2
ApproximateReceiveCount is 1 on the first receive and 2 on the second — the visibility
timeout expired both times with no DeleteMessage, so SQS made the same message visible again
each time and bumped the counter it uses to decide when to redrive.
The Redrive: Main Queue Empties, DLQ Gets the Message
maxReceiveCount is 2, matching the count just reached — the next receive attempt, not the
timeout expiry itself, is what finds the message already moved instead of redelivering it again.
#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws14-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@" --endpoint-url http://aws14-moto:5000; }
Q_URL="http://aws14-moto:5000/123456789012/orders"
DLQ_URL="http://aws14-moto:5000/123456789012/orders-dlq"
sleep 3
echo "main queue receive after 2nd timeout expiry:"
aws sqs receive-message --queue-url "$Q_URL" --query 'Messages[0].Body' --output text
echo "(None above = no Messages key in the response, queue is empty)"
echo "dead-letter queue:"
OUT=$(aws sqs receive-message --queue-url "$DLQ_URL" --query 'Messages[0].[Body,ReceiptHandle]' --output text)
echo "${OUT%$'\t'*}"
# Make it visible again right away (this receive would otherwise hide it for the DLQ's
# own default 30s visibility timeout) so the next lesson section finds it still there.
aws sqs change-message-visibility --queue-url "$DLQ_URL" --receipt-handle "${OUT##*$'\t'}" --visibility-timeout 0 >/dev/null
$ bash redrive.sh
main queue receive after 2nd timeout expiry:
None
(None above = no Messages key in the response, queue is empty)
dead-letter queue:
{"orderId":"o-900"}
The main queue has nothing left to receive, and the DLQ has exactly the original message body —
SQS had already redrove it by the time this third receive attempt ran, since the second
visibility-timeout expiry (after the second receive) pushed ApproximateReceiveCount to the
maxReceiveCount limit, and the receive attempt that would have gone past it is the one that
finds the message in the DLQ instead. No consumer code ever decided to give up.
Practice
1. Send a second message and delete it right after the first receive — confirm it never gets redelivered even after waiting past the visibility timeout.
#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws14-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@" --endpoint-url http://aws14-moto:5000; }
Q_URL="http://aws14-moto:5000/123456789012/orders"
aws sqs send-message --queue-url "$Q_URL" --message-body '{"orderId":"o-901"}' >/dev/null
RECEIPT=$(aws sqs receive-message --queue-url "$Q_URL" --query 'Messages[0].ReceiptHandle' --output text)
aws sqs delete-message --queue-url "$Q_URL" --receipt-handle "$RECEIPT"
sleep 3
echo "receive after delete + wait:"
aws sqs receive-message --queue-url "$Q_URL" --query 'Messages[0].Body' --output text
echo "(None above = nothing to receive)"
$ bash practice1.sh
receive after delete + wait:
None
(None above = nothing to receive)
Nothing comes back — DeleteMessage removes the message from SQS entirely, so there’s no longer
anything for the visibility timeout to re-expose, no matter how long you wait afterward.
2. Send a third message, receive it once, then extend its visibility with ChangeMessageVisibility before the 2-second timeout expires, and confirm it does not redeliver on schedule.
#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws14-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@" --endpoint-url http://aws14-moto:5000; }
Q_URL="http://aws14-moto:5000/123456789012/orders"
aws sqs send-message --queue-url "$Q_URL" --message-body '{"orderId":"o-902"}' >/dev/null
RECEIPT=$(aws sqs receive-message --queue-url "$Q_URL" --query 'Messages[0].ReceiptHandle' --output text)
aws sqs change-message-visibility --queue-url "$Q_URL" --receipt-handle "$RECEIPT" --visibility-timeout 30
sleep 3
echo "receive right after the original 2s window would have expired:"
aws sqs receive-message --queue-url "$Q_URL" --query 'Messages[0].Body' --output text
echo "(None above = still hidden, extension worked)"
$ bash practice2.sh
receive right after the original 2s window would have expired:
None
(None above = still hidden, extension worked)
Still hidden — ChangeMessageVisibility resets the countdown to a new value (30s here) from the
moment it’s called, independent of the queue’s default VisibilityTimeout, which is exactly how a
consumer that knows it needs more time avoids a redelivery mid-processing.
3. Check the dead-letter queue's own RedrivePolicy, confirming it has none (a DLQ is a dead end by default).
#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws14-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@" --endpoint-url http://aws14-moto:5000; }
DLQ_URL="http://aws14-moto:5000/123456789012/orders-dlq"
aws sqs get-queue-attributes --queue-url "$DLQ_URL" --attribute-names All \
--query 'Attributes.RedrivePolicy'
$ bash practice3.sh
null
null — orders-dlq was created with no RedrivePolicy of its own, so a message landing there
stays until something explicitly receives and deletes it; nothing moves it onward automatically.
4. Drain the dead-letter queue: receive and delete its message, then confirm a subsequent receive finds nothing.
#!/usr/bin/env bash
set -e
aws() { MSYS_NO_PATHCONV=1 docker run --rm --network aws14-lab -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli:2.39.5 "$@" --endpoint-url http://aws14-moto:5000; }
DLQ_URL="http://aws14-moto:5000/123456789012/orders-dlq"
RECEIPT=$(aws sqs receive-message --queue-url "$DLQ_URL" --query 'Messages[0].ReceiptHandle' --output text)
aws sqs delete-message --queue-url "$DLQ_URL" --receipt-handle "$RECEIPT"
echo "receive after drain:"
aws sqs receive-message --queue-url "$DLQ_URL" --query 'Messages[0].Body' --output text
echo "(None above = DLQ is now empty)"
$ bash practice4.sh
receive after drain:
None
(None above = DLQ is now empty)
Processing a message off a DLQ is identical to processing one off any other queue — receive, handle it (log it, alert on it, whatever the redrive is meant to catch), then delete it.
Cleanup
$ docker rm -f aws14-moto
aws14-moto
$ docker network rm aws14-lab
aws14-lab
Next: FIFO queues, message groups, and content-based deduplication.