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 RedrivePolicy pointing to a DLQ, failed messages just keep retrying against the source queue indefinitely (or until MessageRetentionPeriod expires and they’re silently dropped).
  • maxReceiveCount never 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 MessageGroupId and MessageDeduplicationId — 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.