A payments team once had an order get charged twice. Not a code bug in the charge logic — the same message had been picked up by two different consumer instances because the Lambda processing it ran long enough for the visibility timeout to expire and put the message back in the queue while the first invocation was still finishing. Nothing crashed. Nothing errored. Two workers just did the same job because the queue’s math didn’t match the job’s runtime.
SQS is simple to use and easy to misconfigure in ways that only show up under load. Here’s how to diagnose the three most common failure modes.
The Problem
Symptoms usually show up as data problems before they show up as AWS console errors:
| Symptom | What It Means |
|---|---|
| Same message processed more than once | Visibility timeout shorter than actual processing time |
| Failed messages never reach the DLQ | Redrive policy missing, or maxReceiveCount never reached because of retries elsewhere |
| Messages sit in queue despite consumers running | Consumers not calling ReceiveMessage with adequate wait time, or IAM permissions block DeleteMessage |
| DLQ fills up immediately | Poison pill message, or downstream dependency outage causing every message to fail on first attempt |
| Standard queue delivers near-duplicates under normal load | Expected at-least-once delivery behavior, not a bug — consumer isn’t idempotent |
SQS guarantees at-least-once delivery on standard queues by design. Most “duplicate” tickets are actually the system working as documented against a consumer that assumed exactly-once.
Why Does This Happen?
- Visibility timeout shorter than processing time: If your consumer takes 90 seconds to process a message but the visibility timeout is 30 seconds, the message becomes visible again and a second consumer picks it up — while the first is still working. This is the single most common cause of duplicate processing.
- No redrive policy configured: Without a
RedrivePolicypointing to a DLQ, failed messages just keep retrying against the source queue indefinitely (or untilMessageRetentionPeriodexpires and they’re silently dropped). maxReceiveCountnever reached: If your Lambda event source mapping retries internally before SQS’s own receive count increments meaningfully, or if a batch failure reports success for the whole batch, individual poison messages don’t accumulate receives the way you’d expect.- Consumer not idempotent: Standard queues are at-least-once by contract. A consumer that isn’t idempotent (no dedup key, no conditional write) will eventually double-process a message — it’s a matter of when, not if.
- Long polling not configured: With short polling (default
WaitTimeSeconds=0), consumers make more empty API calls and can behave inconsistently under low message volume, which sometimes gets misdiagnosed as a delivery bug. - FIFO queue misconfigured for the workload: Using a standard queue where strict ordering and exactly-once processing are required for correctness — instead of a FIFO queue with proper
MessageGroupIdandMessageDeduplicationId— produces duplicates and out-of-order delivery that a FIFO queue would have prevented.
The Fix
Step 1: Compare Visibility Timeout to Actual Processing Time
aws sqs get-queue-attributes \
--queue-url https://sqs.us-east-1.amazonaws.com/111122223333/orders-queue \
--attribute-names VisibilityTimeout \
--output table
Check your actual consumer processing time from logs or tracing, then set the visibility timeout to at least 6x the p99 processing time as a safety margin:
aws sqs set-queue-attributes \
--queue-url https://sqs.us-east-1.amazonaws.com/111122223333/orders-queue \
--attributes VisibilityTimeout=180
For variable-length jobs, call ChangeMessageVisibility from within the consumer to extend the timeout while still processing:
aws sqs change-message-visibility \
--queue-url https://sqs.us-east-1.amazonaws.com/111122223333/orders-queue \
--receipt-handle "AQEB..." \
--visibility-timeout 300
Step 2: Verify the Redrive Policy Is Actually Attached
aws sqs get-queue-attributes \
--queue-url https://sqs.us-east-1.amazonaws.com/111122223333/orders-queue \
--attribute-names RedrivePolicy \
--output json
If it’s empty, attach one:
aws sqs set-queue-attributes \
--queue-url https://sqs.us-east-1.amazonaws.com/111122223333/orders-queue \
--attributes '{"RedrivePolicy":"{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:111122223333:orders-dlq\",\"maxReceiveCount\":5}"}'
Confirm the DLQ’s access policy allows the source queue to send to it — this is a common silent gap:
aws sqs get-queue-attributes \
--queue-url https://sqs.us-east-1.amazonaws.com/111122223333/orders-dlq \
--attribute-names Policy
Step 3: Check Real Receive Counts on Stuck Messages
Peek at a message without consuming it, and inspect its approximate receive count:
aws sqs receive-message \
--queue-url https://sqs.us-east-1.amazonaws.com/111122223333/orders-queue \
--attribute-names ApproximateReceiveCount \
--max-number-of-messages 1
A high ApproximateReceiveCount with no corresponding DLQ entry usually means the redrive policy was added after the message was already stuck in a retry loop — it only applies going forward.
Step 4: Check for Lambda Event Source Mapping Batch Failure Reporting
If Lambda is the consumer, confirm partial batch failure reporting is enabled — otherwise one bad message in a batch causes the entire batch to retry, not just the offending record:
aws lambda get-event-source-mapping \
--uuid 12345678-90ab-cdef-1234-567890abcdef \
--query "FunctionResponseTypes"
Should include ReportBatchItemFailures. If not, update it:
aws lambda update-event-source-mapping \
--uuid 12345678-90ab-cdef-1234-567890abcdef \
--function-response-types ReportBatchItemFailures
Step 5: Enable Long Polling
aws sqs set-queue-attributes \
--queue-url https://sqs.us-east-1.amazonaws.com/111122223333/orders-queue \
--attributes ReceiveMessageWaitTimeSeconds=20
Step 6: Make the Consumer Idempotent
This is application-level, not an AWS setting, but it’s the real fix for standard-queue duplicates: track processed message IDs (or a business-level idempotency key) in DynamoDB with a conditional write, and skip processing if the key already exists.
aws dynamodb put-item \
--table-name processed-messages \
--item '{"message_id": {"S": "abc-123"}}' \
--condition-expression "attribute_not_exists(message_id)"
If this call fails with ConditionalCheckFailedException, the message was already processed — safely discard the duplicate.
Step 7: Move to FIFO if Ordering and Exactly-Once Semantics Are a Hard Requirement
aws sqs create-queue \
--queue-name orders-queue.fifo \
--attributes '{"FifoQueue":"true","ContentBasedDeduplication":"true"}'
FIFO queues deduplicate within a 5-minute window using MessageDeduplicationId and preserve order within a MessageGroupId — but throughput is lower than standard queues, so only migrate if correctness actually requires it.
Is This Safe?
The get-queue-attributes and receive-message (peek) calls are read-only or non-destructive. Changing VisibilityTimeout and enabling long polling take effect immediately and are safe to adjust live. Attaching a redrive policy is additive and safe. Migrating from a standard to a FIFO queue is not an in-place operation — it requires creating a new queue and updating producers/consumers, so plan that as a deployment, not a hotfix.
Key Takeaway
Most SQS “bugs” are visibility timeout math that doesn’t match real processing time, or a DLQ that was configured too late to catch a message already stuck in a retry loop. Set the visibility timeout with real headroom, always attach a DLQ from day one, and build consumers assuming at-least-once delivery — because on a standard queue, that’s the guarantee you actually have.
Have questions or ran into a different SQS issue? Connect with me on LinkedIn or X.