Debugging Duplicate Deliveries in SQS
Duplicate delivery is a symptom, and the visibility timeout is nearly always the cause. This guide, part of Queue Fundamentals & Architecture, narrows a real duplicate down to one of five mechanisms and fixes each.
Problem Statement
Your order-processing consumer sends confirmation emails, and roughly 0.3% of customers receive two. CloudWatch shows NumberOfMessagesReceived about 15% higher than NumberOfMessagesDeleted, which means SQS is handing out messages that never get acknowledged. The consumer logs show no errors. You need to determine whether the duplicates come from timeout expiry, a crashed worker, a misconfigured poll, or an at-least-once delivery you simply have to tolerate — and then make the handler safe regardless.
Prerequisites
- CloudWatch metrics for the queue, and consumer logs that record the message ID and receipt handle.
- Permission to call
GetQueueAttributes,ReceiveMessagewithApproximateReceiveCount, andChangeMessageVisibility. - Known p50/p99 handler durations — every diagnosis below compares them against the timeout.
Step 1 — Capture the Receive Count
ApproximateReceiveCount is the single most informative field, and it is not returned unless you ask for it.
import boto3
sqs = boto3.client("sqs")
resp = sqs.receive_message(
QueueUrl=QUEUE_URL,
MaxNumberOfMessages=10,
WaitTimeSeconds=20, # long polling — see step 4
AttributeNames=["ApproximateReceiveCount", "SentTimestamp",
"ApproximateFirstReceiveTimestamp"],
)
for msg in resp.get("Messages", []):
attrs = msg["Attributes"]
receive_count = int(attrs["ApproximateReceiveCount"])
first_seen = int(attrs["ApproximateFirstReceiveTimestamp"]) / 1000
if receive_count > 1:
log.warning("redelivery", extra={
"message_id": msg["MessageId"],
"receive_count": receive_count,
"seconds_since_first_receive": time.time() - first_seen,
})
The gap between receives is the diagnosis. If it equals the visibility timeout almost exactly, the timeout expired while the handler was still running. If it is much shorter, the consumer crashed or the message was explicitly released. If it is much longer, something changed the visibility mid-flight.
Step 2 — Compare Timeout Against Real Runtime
The most common cause is boring: p99 handler duration exceeds the visibility timeout, so a slow-but-successful job is redelivered while it is still working.
# What the queue is actually configured with
aws sqs get-queue-attributes --queue-url "$QUEUE_URL" \
--attribute-names VisibilityTimeout ReceiveMessageWaitTimeSeconds \
RedrivePolicy ApproximateNumberOfMessagesNotVisible
# The rule: timeout > p99 duration × safety factor, and > any batch's total time
def required_visibility_timeout(p99_seconds: float, batch_size: int = 1) -> int:
# A batch is processed serially: the LAST message in the batch has been
# invisible for the whole batch, not just for its own handler run.
return int(p99_seconds * batch_size * 1.5) + 10
required_visibility_timeout(18) # 37s — single-message receive
required_visibility_timeout(18, 10) # 280s — MaxNumberOfMessages=10
The batch multiplier catches many teams by surprise. With MaxNumberOfMessages=10 and an 18-second handler, the tenth message has been invisible for roughly three minutes before it is even touched, so a 60-second timeout guarantees it is redelivered.
Step 3 — Extend Visibility from a Heartbeat
For handlers whose duration varies widely, extend rather than over-provision. A long fixed timeout delays legitimate redelivery after a real crash.
import threading
def with_heartbeat(queue_url: str, receipt_handle: str, interval: int = 20,
extend_to: int = 60):
"""Keep a message invisible while the handler runs; stop on completion."""
stop = threading.Event()
def beat():
while not stop.wait(interval):
try:
sqs.change_message_visibility(
QueueUrl=queue_url, ReceiptHandle=receipt_handle,
VisibilityTimeout=extend_to)
except sqs.exceptions.ReceiptHandleIsInvalid:
# Already deleted or expired — nothing left to extend.
return
t = threading.Thread(target=beat, daemon=True)
t.start()
return stop # caller sets it in a finally block
Extending beyond the queue's 12-hour maximum silently fails, and a heartbeat thread that outlives its handler keeps a deleted message invisible — so always stop it in a finally.
Step 4 — Rule Out Poll and Client Misconfiguration
Three settings produce duplicates that look like timeout problems but are not.
| Setting | Bad value | Effect |
|---|---|---|
WaitTimeSeconds |
0 (short poll) | Samples a subset of servers; empty responses and uneven delivery |
| Client socket timeout | < WaitTimeSeconds |
The SDK abandons a request whose messages SQS already marked received |
VisibilityTimeout on ReceiveMessage |
overrides the queue default | A per-call value silently shortens the window |
| Multiple consumer groups | same queue | Every consumer receives every message — you need one queue per consumer |
Short polling deserves particular attention: with WaitTimeSeconds=0, SQS samples a subset of its servers, so a message can appear absent to one poll and present to the next. Always use long polling (20 seconds) unless you have a specific reason not to.
A client socket timeout below the long-poll duration is subtler and worse: the SDK gives up while SQS has already counted the receive and started the visibility clock. Nothing processes those messages until the timeout expires, and the retry looks exactly like a duplicate.
from botocore.config import Config
sqs = boto3.client("sqs", config=Config(
read_timeout=25, # MUST exceed WaitTimeSeconds (20)
connect_timeout=5,
retries={"max_attempts": 3, "mode": "standard"},
))
Step 5 — Make Duplicates Harmless
SQS standard queues are at-least-once by design. Even with a perfect timeout, the service can deliver a message twice — so the only complete fix is an idempotent handler.
import hashlib
def dedup_key(msg: dict) -> str:
"""Derive identity from the business payload, not the message ID: a genuine
SQS duplicate has a new MessageId but the same content."""
body = json.loads(msg["Body"])
return hashlib.sha256(f"{body['order_id']}:{body['event']}".encode()).hexdigest()
def handle(msg: dict) -> None:
key = dedup_key(msg)
# SET NX is the whole mechanism: first writer wins, everyone else no-ops.
if not r.set(f"dedup:{key}", "1", nx=True, ex=86_400):
log.info("duplicate suppressed", extra={"key": key})
return
try:
process(json.loads(msg["Body"]))
except Exception:
r.delete(f"dedup:{key}") # allow a genuine retry after a real failure
raise
FIFO queues offer server-side deduplication within a five-minute window via MessageDeduplicationId, which handles producer-side duplicates but not consumer-side redelivery. The application-level key covers both, and the patterns are developed further in preventing duplicate job execution with idempotency.
Step 6 — Watch the Right CloudWatch Metrics
# Redelivery ratio over the last hour
aws cloudwatch get-metric-statistics --namespace AWS/SQS \
--metric-name NumberOfMessagesReceived --statistics Sum --period 3600 \
--dimensions Name=QueueName,Value=orders --start-time "$(date -u -d '1 hour ago' +%FT%TZ)" \
--end-time "$(date -u +%FT%TZ)"
Step 7 — Choose Between a Long Timeout and a Heartbeat
Both fixes stop the redelivery; they fail differently, and the difference matters once a worker actually crashes.
| Long fixed timeout | Heartbeat extension | |
|---|---|---|
| Slow-but-healthy handler | Safe | Safe |
| Worker crash | Message stuck invisible for the full timeout | Message returns within one heartbeat interval |
| Configuration surface | One queue attribute | Code in every consumer |
| Failure mode | Recovery delayed by minutes or hours | Thread outlives handler, holds a deleted message |
| Best for | Uniform, predictable durations | Wide duration spread, or slow outliers |
The decisive question is how quickly you need a crashed worker's message to be retried. With a 15-minute timeout sized for a p99 job, a worker that dies one second into a job leaves that message invisible for fifteen minutes — during which a customer is waiting and no alert fires, because depth looks normal and nothing is failing. A heartbeat with a 60-second base and a 20-second interval bounds that recovery at a minute regardless of how long the job would have taken.
The practical rule: if p50 and p99 are within a factor of two or three, set a fixed timeout from p99 and keep the consumers simple. If p99 is ten times p50 — common when the same handler processes both a two-row and a two-million-row account — heartbeat, and set the base timeout from something close to p50 so crash recovery stays fast. Mixing the two, a generous fixed timeout and a heartbeat, is the worst of both: you pay the code complexity and still wait the full timeout after a crash.
Verification
- Receive counts drop. After raising the timeout, messages with
ApproximateReceiveCount > 1should fall to near zero outside genuine failures. - Received ≈ deleted. The CloudWatch gap should close to within a percent.
- Duplicates are suppressed anyway. Deliberately re-send an identical payload; the dedup key must short-circuit it and log a suppression.
- The heartbeat stops. After a handler completes, no further
ChangeMessageVisibilitycalls for that receipt handle should appear in CloudTrail.
Gotchas & Edge Cases
Receipt handles change on every receive. A handle captured on receive one cannot delete the message after receive two. Always delete with the handle from the current receive.
Visibility timeout is capped at 12 hours. A heartbeat cannot extend past it. Jobs longer than that must checkpoint, as in configuring visibility timeouts for long-running workers.
Deleting after a partial failure creates silent data loss. If the handler half-succeeded, deleting hides the problem. Either make the whole handler idempotent and re-runnable, or let the message return and rely on the dedup key.
FIFO deduplication only covers five minutes and only the producer side. It is not a substitute for an idempotent consumer.
Lambda triggers manage visibility themselves. With an SQS event source, the function timeout must be below the queue's visibility timeout — AWS recommends at least six times the function timeout — or Lambda's own retries manufacture the duplicates you are chasing.
Related
- Visibility Timeout Deep Dive — the mechanism these symptoms come from.
- Configuring visibility timeouts for long-running workers — heartbeat extension in depth.
- Preventing duplicate job execution with idempotency — the deduplication layer that makes redelivery safe.
- Configuring an SQS redrive policy — where messages go once the receive count is genuinely exhausted.