Skip to main content
AWS · lesson 14 of 19
AWS intermediate Lesson 14 of 19

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

aws sqs messaging

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.

Frequently Asked Questions

What does SQS's visibility timeout actually control?
When a consumer calls ReceiveMessage, SQS doesn't delete the message — it hides it from other ReceiveMessage calls for the queue's VisibilityTimeout, betting that the consumer is about to process and then DeleteMessage it. If that timeout expires with no DeleteMessage, SQS assumes the consumer crashed or hung and makes the message visible again, incrementing ApproximateReceiveCount — which is exactly the mechanism this lesson's 2-second timeout demonstrates without a real crash involved.
What triggers a redrive to the dead-letter queue, exactly?
A queue's RedrivePolicy names a deadLetterTargetArn and a maxReceiveCount. The move isn't tied to a timeout expiry itself — it happens on the next receive attempt after ApproximateReceiveCount has already reached maxReceiveCount. That receive attempt finds the message moved to the dead-letter queue instead of redelivering it, which is why this lesson's third receive attempt (after the count hit 2 on the second attempt) is the one that sees it gone from the source queue.
Is the dead-letter queue a special kind of SQS queue?
No — it's an ordinary queue, created the same way as any other (this lesson's orders-dlq uses create-queue with no special flags). What makes it a 'DLQ' is only that some other queue's RedrivePolicy names it as a target; nothing on the queue itself marks it as one, and it can even have its own RedrivePolicy pointing to yet another queue.
How is this different from a generic distributed dead-letter-queue pattern?
The pattern is the same message-moves-after-repeated-failure idea covered generically in event-driven/dead-letter-queue; this lesson is the AWS-specific mechanics on top of it: the exact RedrivePolicy JSON shape, ApproximateReceiveCount as the counter SQS itself maintains, and the fact that the move happens automatically on receive, with no consumer code involved in deciding when to give up.