AWS Integration

SQS Message Processing & DLQs: The Stuck-Messages Troubleshooting Playbook

The queue is 40,000 messages deep, the age of the oldest message just crossed three hours, and the on-call channel has two contradictory reports: one team says the same order shipped twice, another says their message “never arrived.” Both are true, and both are SQS behaving exactly as designed — you just have not yet named which of a dozen very specific failure classes each one is. Amazon SQS almost never “loses” a message at random. It duplicates on an at-least-once queue when your visibility timeout is shorter than your consumer’s runtime, it strands messages in-flight when a consumer crashes holding them, it moves a poison message to a dead-letter queue after maxReceiveCount receives, and it silently drops a message when retention expires before anyone processed it. Each of those is a different root cause with a different confirming metric and a different fix, and the entire skill of operating a queue at 3 a.m. is telling them apart fast.

This is the reference you keep open during the incident. It treats SQS as what it actually is — a visibility-timeout state machine wrapped around an at-least-once (standard) or exactly-once-processing (FIFO) delivery contract, with a dead-letter queue as the catch-net and a redrive path back — and it walks every message-processing failure that pages you: duplicate processing (visibility timeout below consumer runtime, no idempotency), stuck / invisible messages (a crashed consumer holding them in-flight until the timeout, and the hard in-flight limit — 120,000 standard, 20,000 FIFO — that returns OverLimit), messages going to the DLQ (maxReceiveCount reached, poison payloads, a consumer that always throws), messages not going to the DLQ (a missing or wrong redrive policy, a DLQ whose retention is shorter than the source, a type or encryption mismatch), the Lambda event-source-mapping traps (without ReportBatchItemFailures one bad record reprocesses the whole batch and duplicates the nine good ones), FIFO stuck (a message group blocked by one un-acked message, and deduplication silently swallowing a resend), redrive from the DLQ back to source, and the quiet killer — silent loss when retention expires before processing.

By the end you will read ApproximateAgeOfOldestMessage and ApproximateNumberOfMessagesNotVisible like an instrument panel, know from a single metric whether you are looking at a backlog, an in-flight leak, or a poison-message loop, and carry a # → symptom → category → root cause → confirm → fix playbook you can run under pressure. Everything is shown in both aws CLI and Terraform, with the real limits and error strings — never a hand-waved number. It maps directly to the integration and messaging domains of SAA-C03, DVA-C02 and SOA-C02.

What problem this solves

A queue decouples a producer from a consumer, and in doing so it hides every failure behind an approximate count and an eventually-consistent state. There is no transaction log you can tail, no single “where is my message” query — a message is either visible (available), not visible (in-flight, received by someone who has not deleted it yet), delayed, or gone (deleted, or expired past retention), and the only windows you have into which state a message is in are a handful of CloudWatch metrics that are themselves approximate. When processing is duplicating, stalling, or dropping, you diagnose it entirely from those metrics plus the queue’s attributes — and if you do not know which number means what, you will “fix” a duplicate that was really a too-short visibility timeout, add consumers to a queue that is stuck on the in-flight cap, or redrive a DLQ whose messages already expired.

What breaks without this knowledge is correctness and money, silently. A team sets a 30-second visibility timeout on a queue whose consumer takes 45 seconds; every message is delivered a second time mid-processing and every order ships twice — the queue depth looks healthy the whole time. Another wires a Lambda to an SQS queue, hits one malformed record in a batch of ten, and — because they never set ReportBatchItemFailures — watches the whole batch reappear and reprocess, so the nine good records run again and again while the one bad record inches toward the DLQ. A third points a redrive policy at a DLQ ARN with a typo, sleeps soundly because “we have a DLQ,” and discovers weeks later that poison messages have been expiring in the source queue at retention the entire time, caught by nothing. None of these are exotic. They are the default behaviour of a queue you have not configured against.

Who hits this: anyone running asynchronous processing past “hello world.” It bites hardest on teams new to at-least-once delivery and the visibility-timeout model, on high-throughput consumers where the in-flight cap and batch semantics are load-bearing, on FIFO users who assume ordering is free and discover one stuck message blocks an entire group, and on anyone who assumed a failed message would “obviously” end up in a dead-letter queue. The fix is almost never “add more consumers and hope.” It is: name the failure class, read the one metric that confirms it, and apply the one attribute or ESM setting that addresses it.

Here is the whole failure field on one screen — the class, where it bites hardest, the exact signal, and the one-line fix — so you can orient before the deep dive:

Failure class Bites hardest on The signal you’ll see The one-line fix
Duplicate processing Standard queues Same side effect twice; ApproximateReceiveCount > 1 Visibility ≥ processing time; idempotency key
Stuck / invisible Any queue NotVisible high, Visible ≈ 0, no progress Shorter visibility or heartbeat; delete on success
In-flight OverLimit Slow consumers OverLimit on ReceiveMessage; NotVisible ≈ 120k/20k Delete faster; more consumers; shorter visibility
Poison → DLQ Bad payloads / bugs DLQ depth climbing; ApproximateReceiveCount ≈ max Fix handler; inspect DLQ; redrive after fix
Not going to DLQ Misconfigured redrive Poison loops forever or expires; DLQ stays empty Fix redrive policy ARN/type; DLQ retention ≥ source
Whole-batch reprocess Lambda ESM 9 good records reprocess with the 1 bad ReportBatchItemFailures + return failed IDs
FIFO group block FIFO queues One group stalls; AgeOfOldest climbs, others fine Delete/handle the head message; DLQ the group
FIFO dedup swallow FIFO producers “Sent” message never delivered Unique MessageDeduplicationId per distinct message
Silent loss Any queue Message gone, DLQ empty, no error Retention ≥ max age; DLQ before retention; alarms
KMS / permission Encrypted queues KMS.AccessDeniedException; can’t send/receive kms:Decrypt/GenerateDataKey; queue policy

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already be comfortable with the SQS basics: a producer calls SendMessage, a consumer calls ReceiveMessage (which hides the message for the visibility timeout), processes it, and then calls DeleteMessage — and if it never deletes, the message reappears after the timeout. You should know that a standard queue is at-least-once with best-effort ordering and effectively unlimited throughput, while a FIFO queue is exactly-once-processing with strict ordering per message group. You should be able to run aws sqs from a shell, read JSON with --query, and read a Terraform aws provider resource block. The ideas of “retry,” “idempotency,” and “poison message” should be familiar.

This sits in the Integration & Messaging track and is the operational companion to the design-side references. The queue types themselves — standard vs FIFO, ordering, throughput, and when to pick which — are built hands-on in SQS Queues: Standard vs FIFO, Hands-On; the pub/sub sibling that fans one message out to many queues is covered in SNS Topics: Fan-Out & Subscriptions, Hands-On; and the larger pattern that wires buses, queues and functions together is Event-Driven Architecture with EventBridge, SQS & Lambda. Because the most common SQS consumer is a Lambda function, the failure modes here overlap deeply with AWS Lambda Errors, Timeouts & Cold Starts: The Troubleshooting Playbook — this article owns the queue side of that boundary, that one owns the function side.

Before the deep dive, fix the mental model of where each failure lives, so you look in the right place first:

Layer What lives here Failure classes it causes First place to look
Producer SendMessage, dedup, message group FIFO dedup swallow; wrong group; oversized (>256 KB) NumberOfMessagesSent; producer logs
Queue config Visibility, retention, redrive policy, encryption Duplicates; silent loss; DLQ-not-catching get-queue-attributes
In-flight window Received-but-not-deleted messages Stuck/invisible; OverLimit; FIFO group block ApproximateNumberOfMessagesNotVisible
Consumer ReceiveMessage → process → DeleteMessage Duplicates; poison loops; whole-batch reprocess Consumer logs; ApproximateReceiveCount
DLQ + redrive The catch-net and the replay path Poison caught (or not); stale DLQ DLQ depth; redrive-allow-policy
Observability CloudWatch metrics You can’t confirm anything CloudWatch Metrics + alarms

Core concepts

The message lifecycle — the visibility-timeout state machine

Every SQS message moves through a small state machine, and knowing which state a message is in is half of every diagnosis. A message is visible (available to be received), becomes not visible (in-flight) the instant a consumer receives it, and stays that way for the visibility timeout — during which no other consumer can see it — until the consumer either deletes it (done) or the timeout expires (it becomes visible again and will be redelivered). A message can also be delayed (not yet visible, held by a delay queue or per-message timer), and it is permanently gone when deleted or when its retention period expires.

State What it means The metric that counts it What moves it out
Visible Available for ReceiveMessage ApproximateNumberOfMessagesVisible A consumer receives it → not visible
Not visible (in-flight) Received, hidden for the visibility timeout ApproximateNumberOfMessagesNotVisible DeleteMessage (gone), or timeout expiry (→ visible)
Delayed Held before first delivery ApproximateNumberOfMessagesDelayed Delay elapses → visible
Gone (deleted) Consumer confirmed success — (drops out of all counts) Terminal
Gone (expired) Retention period elapsed unprocessed — (silent; count just drops) Terminal — this is silent loss

The single most useful signal in all of SQS is ApproximateAgeOfOldestMessage — the age, in seconds, of the oldest message still in the queue. Learn to read the four numbers together:

You observe It means Likely class
Visible high, NotVisible low, AgeOfOldest climbing Backlog: consumers can’t keep up Under-provisioned consumer
Visible ≈ 0, NotVisible high, no progress Messages received but never deleted Stuck / invisible; crashed consumer
NotVisible pinned at 120k/20k, OverLimit on receive In-flight cap reached OverLimit
AgeOfOldest near retention, count dropping Messages expiring unprocessed Silent loss
DLQ Visible climbing Poison messages caught Poison → DLQ (working)
Poison loops forever, DLQ Visible = 0 Redrive misconfigured DLQ-not-catching

Standard vs FIFO — the contract changes the failure modes

The two queue types fail differently because their delivery contracts differ. This table is the one you memorise; nearly every “why did it duplicate / why is it stuck” question is answered by reading the correct row.

Property Standard FIFO
Delivery At-least-once (duplicates possible) Exactly-once processing (dedup within 5 min)
Ordering Best-effort Strict, per MessageGroupId
Throughput Effectively unlimited 300 msg/s (3,000 batched); high-throughput mode: much higher
In-flight limit 120,000 20,000
Duplicate cause Redelivery on timeout expiry Only if you reuse a dedup ID after 5 min, or delete fails
Stuck cause A held message hides only itself A held message blocks its whole group
Name suffix any must end .fifo
DLQ type required standard DLQ FIFO DLQ

Three consequences fall straight out of this table and cause most incidents: (1) a standard queue will deliver a message twice, so idempotency is not optional; (2) a FIFO queue trades throughput and adds a brand-new failure mode — one stuck message stalls every message behind it in the same group; (3) a message that keeps failing is retried on both types until maxReceiveCount, then moved to a DLQ of the matching type.

The attributes that shape every behaviour

Almost every failure in this article traces back to one of a handful of queue attributes being wrong. Here is the full set, with the defaults, ranges, and the gotcha for each:

Attribute Values / range Default When to change Gotcha
VisibilityTimeout 0 s – 43,200 s (12 h) 30 s Match to processing time (+ margin) Too low → duplicates; too high → stuck-message stall
MessageRetentionPeriod 60 s – 1,209,600 s (14 d) 4 days Longer for slow/critical consumers Expiry = silent loss; DLQ must be ≥ source
ReceiveMessageWaitTimeSeconds 0 – 20 s 0 (short poll) Set 20 for long polling Short poll = empty receives + higher cost
DelaySeconds 0 – 900 s (15 min) 0 Delay all messages Per-message timer overrides queue delay
maxReceiveCount (redrive) 1 – 1,000 none Number of tries before DLQ Too high → poison loops long; too low → transient faults DLQ’d
RedrivePolicy JSON: deadLetterTargetArn + maxReceiveCount none To enable a DLQ Wrong ARN / type = not caught
RedriveAllowPolicy (on DLQ) allowAll / denyAll / byQueue allowAll Restrict which sources may use this DLQ denyAll blocks redrive back
KmsMasterKeyId (SSE-KMS) CMK id/alias none (or SSE-SQS) Customer-managed encryption Consumers need kms:Decrypt
SqsManagedSseEnabled (SSE-SQS) true/false varies Free AWS-managed encryption No per-key control
ContentBasedDeduplication (FIFO) true/false false Auto dedup by body hash Legit repeated bodies get swallowed
# Read the attributes that explain 90% of incidents
aws sqs get-queue-attributes --queue-url "$Q_URL" --attribute-names \
  VisibilityTimeout MessageRetentionPeriod RedrivePolicy \
  ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible \
  ApproximateAgeOfOldestMessage

Duplicate processing: the at-least-once tax

A standard SQS queue guarantees a message is delivered at least once — which means it may be delivered more than once, and your consumer must be built for that. Duplicates are not a bug in SQS; they are the contract. But most “we processed it twice” incidents are not the rare infrastructure duplicate — they are a visibility timeout set shorter than the consumer’s actual runtime, so SQS makes the message visible again and hands it to a second consumer while the first is still working.

Root cause What’s actually happening Confirm Fix
Visibility < processing time Message reappears mid-processing; a second consumer grabs it ApproximateReceiveCount > 1; two consumers logging the same MessageId Raise VisibilityTimeout above p99 processing; or extend with a heartbeat
Consumer crashes before delete Work done (side effect fired) but DeleteMessage never ran Side effect present, message redelivered Make the side effect idempotent; delete only after success
At-least-once redelivery SQS’s inherent duplicate (rare, by design) Occasional dup with no timeout issue Idempotency key — the only real fix
Batch delete partial failure DeleteMessageBatch failed for some entries; they redeliver Check the batch delete Failed[] response Retry failed deletes; check per-entry results
Producer sent twice Retry on the producer side without dedup NumberOfMessagesSent higher than expected FIFO with dedup ID, or idempotency downstream

The durable fix for all five is idempotency: make processing the same message twice have the same effect as processing it once. You do not prevent the duplicate delivery; you make it harmless.

Idempotency approach How it works Best for Cost / caveat
Conditional write PutItem with attribute_not_exists(id) in DynamoDB keyed on MessageId or a business key Any consumer One extra write; pick the right key
Dedup table with TTL Record processed IDs; skip if seen; TTL expires old rows High volume Table + TTL; window must exceed retention
Natural idempotency The operation is inherently repeatable (SET x=5, not x+=1) State overwrites Not always possible
FIFO exactly-once The queue dedups within a 5-minute window Ordered, low-throughput Only 5 min; not a substitute for idempotency
Upstream idempotency key Downstream API dedups on an Idempotency-Key header Calling external APIs Depends on the API supporting it

The subtle version of this bug is extending visibility instead of raising it. If processing time varies wildly — most messages take 5 seconds but some take 5 minutes — do not set a 5-minute visibility timeout for everything (it makes every stuck message stall for 5 minutes). Instead, set a modest timeout and call ChangeMessageVisibility as a heartbeat while you work, extending the window in small increments. If the consumer dies, the short remaining window expires quickly and the message is retried promptly.

# Set visibility to comfortably exceed p99 processing time
aws sqs set-queue-attributes --queue-url "$Q_URL" \
  --attributes VisibilityTimeout=180

# Heartbeat pattern: extend a specific in-flight message while still working
aws sqs change-message-visibility --queue-url "$Q_URL" \
  --receipt-handle "$RECEIPT" --visibility-timeout 120

Stuck / invisible messages and the in-flight limit

The most confusing SQS state is a queue where ApproximateNumberOfMessagesVisible is near zero, ApproximateNumberOfMessagesNotVisible is large, and nothing is being processed. Every message has been received by some consumer that then crashed, hung, or forgot to delete it — so it sits in-flight, invisible to everyone else, until its visibility timeout expires. With a long visibility timeout (say 12 hours), a batch of messages a dead consumer grabbed is effectively frozen for 12 hours.

Symptom Root cause Confirm Fix
NotVisible high, Visible ≈ 0, no progress Consumers received then died holding messages ApproximateNumberOfMessagesNotVisible high; consumer logs stop Shorter visibility or heartbeat; fix the crash; wait out the timeout
AgeOfOldest climbing, NotVisible churning Messages redelivered repeatedly, never deleted ApproximateReceiveCount climbing on the same IDs Delete on success; check the delete path/permissions
ReceiveMessage returns OverLimit In-flight cap (120k/20k) reached NotVisible ≈ 120,000 (or 20,000 FIFO) Delete faster; add consumers; shorten visibility
New sends succeed but nothing is received Cap reached; receives blocked OverLimit on receive, sends fine Drain in-flight; then receives resume

The hard ceiling here is the in-flight (not-visible) message limit: 120,000 messages for a standard queue and 20,000 for a FIFO queue. Once that many messages are in-flight (received but not yet deleted or timed out), further ReceiveMessage calls return the error OverLimit (AWS.SimpleQueueService.OverLimit). This is not a quota you can raise; it is a signal that your consumers are receiving far faster than they are deleting — usually because processing is slow, the visibility timeout is very long, or a fleet of consumers crashed with messages held.

In-flight limit Standard FIFO
Max messages in-flight 120,000 20,000
Error when exceeded OverLimit on ReceiveMessage OverLimit on ReceiveMessage
Raisable? No (hard limit) No (hard limit)
Typical trigger Slow consumers + long visibility A blocked group holding messages
The real fix Delete faster / shorter visibility / more consumers Unblock the group; delete the head

The tools to keep in-flight under control are the visibility timeout, the heartbeat, and a batch of visibility operations for bulk correction:

Lever What it does Use when
Lower VisibilityTimeout In-flight messages return sooner if not deleted Consumers crash often; you want fast retry
ChangeMessageVisibility (per message) Extend one message you’re still processing Long-tail processing times
ChangeMessageVisibilityBatch Extend/adjust up to 10 at once Bulk correction
Set visibility to 0 on a receipt Immediately return a message to the queue Abort processing, retry now
More / faster consumers Raise delete rate below the send rate Backlog with healthy processing

Messages going to the DLQ: poison messages and maxReceiveCount

A dead-letter queue (DLQ) is an ordinary SQS queue that a source queue points to via its redrive policy. The mechanism is simple and mechanical: every time a message is received without being deleted, its receive count increments; when that count exceeds maxReceiveCount, SQS moves the message to the DLQ. A DLQ is therefore a quarantine for messages that keep failing — almost always a poison message (a payload your consumer cannot process) or a consumer bug that throws on a specific shape.

Redrive policy field Meaning Range Gotcha
deadLetterTargetArn ARN of the DLQ valid SQS ARN Wrong ARN / deleted queue = silent no-catch
maxReceiveCount Receives before a message is dead-lettered 1 – 1,000 Counts receives, not processing failures

A message reaches the DLQ only when it is received maxReceiveCount times without being deleted. That distinction matters: the count increments on ReceiveMessage, so a message that is never received (or is received and deleted) never dead-letters. The most common poison categories:

Poison category Example Why it always fails Where the fix lives
Malformed payload Truncated JSON, wrong schema Parser throws every time Validate on send; version the schema
Missing/null field total is null, code does math on it NullPointerException/TypeError each retry Defensive parsing; validate
Reference to gone data Points at a deleted S3 object / row Downstream 404 every time Skip-and-log; or fix ordering
Oversized / encoding Non-UTF-8 body, bad encoding Deserialization fails Producer-side validation
Consumer bug Handler throws on a valid but unusual shape Deterministic crash Fix the handler, then redrive
Downstream hard-fail A permanent 403/400 from a dependency Never succeeds on retry Fix perms/contract; redrive

The right operational posture: a DLQ is not a graveyard, it is an inbox. Alarm on DLQ depth (ApproximateNumberOfMessagesVisible on the DLQ) so a single poison message pages you; inspect the message and its attributes (especially ApproximateReceiveCount and the original send timestamp); fix the root cause; then redrive the DLQ back to the source once the consumer can handle it.

# Create a DLQ, then attach a redrive policy on the source queue (max 5 tries)
DLQ_ARN=$(aws sqs get-queue-attributes --queue-url "$DLQ_URL" \
  --attribute-names QueueArn --query Attributes.QueueArn --output text)
aws sqs set-queue-attributes --queue-url "$SRC_URL" --attributes \
  "{\"RedrivePolicy\":\"{\\\"deadLetterTargetArn\\\":\\\"$DLQ_ARN\\\",\\\"maxReceiveCount\\\":\\\"5\\\"}\"}"
# Terraform: source queue + DLQ + redrive policy + redrive-allow on the DLQ
resource "aws_sqs_queue" "orders_dlq" {
  name                      = "orders-dlq"
  message_retention_seconds = 1209600 # 14 days — longer than the source
}

resource "aws_sqs_queue" "orders" {
  name                       = "orders"
  visibility_timeout_seconds = 180
  message_retention_seconds  = 345600 # 4 days
  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.orders_dlq.arn
    maxReceiveCount     = 5
  })
}

resource "aws_sqs_queue_redrive_allow_policy" "dlq_allow" {
  queue_url = aws_sqs_queue.orders_dlq.id
  redrive_allow_policy = jsonencode({
    redrivePermission = "byQueue"
    sourceQueueArns   = [aws_sqs_queue.orders.arn]
  })
}

Messages NOT going to the DLQ: the silent-loss traps

The inverse failure is worse because it is invisible: you have a DLQ, you sleep soundly, and messages that should be quarantined are instead looping forever or expiring unnoticed. Every one of these is a configuration mismatch that stops the redrive mechanism from firing.

Reason the DLQ doesn’t catch What actually happens Confirm Fix
No redrive policy Poison message retries until retention, then is deleted get-queue-attributes shows no RedrivePolicy Add a RedrivePolicy with the DLQ ARN
Wrong / typo DLQ ARN Redrive points at a non-existent queue ARN in policy ≠ real DLQ ARN Correct the ARN
Type mismatch Standard source → FIFO DLQ (or vice-versa) SetQueueAttributes errors, or never moves DLQ type must match source type
Different account / region DLQ not in the same account+region as source Cross-account/region ARN DLQ must be same account and region
maxReceiveCount too high Message expires (retention) before hitting the count Retention < (maxReceiveCount × visibility) Lower maxReceiveCount; raise retention
Consumer deletes on failure Bug deletes the message even when processing failed Side effect missing, message gone, DLQ empty Delete only after confirmed success
Lambda swallows the error Handler catches the exception and returns success Function Errors = 0 though work failed Let it throw, or use ReportBatchItemFailures
DLQ retention < source Message moved to DLQ but expires almost immediately DLQ MessageRetentionPeriod < source DLQ retention ≥ source (set it to 14 days)
redrive-allow-policy = denyAll DLQ refuses to accept from that source DLQ policy redrivePermission = denyAll Set allowAll or byQueue with the source ARN

Two of these deserve emphasis because they cause silent data loss even with a “working” DLQ.

First, DLQ retention shorter than the source. A message’s expiry is always measured from its original enqueue timestamp, not from when it was moved to the DLQ. If a message spent three days in the source (4-day retention) and is then moved to a DLQ whose retention is one day, it is already older than the DLQ’s retention and gets deleted almost immediately — the DLQ “caught” it and lost it in the same breath. Always set the DLQ’s retention to the maximum (14 days), longer than any source.

Second, the encryption and permission requirements. If the source queue is encrypted with a customer-managed KMS key (SSE-KMS), the same key must be usable for the DLQ path, and every principal that reads the DLQ needs kms:Decrypt (and kms:GenerateDataKey for sends). A DLQ your operators cannot decrypt is a DLQ you cannot inspect. The requirements to get right, all at once:

DLQ requirement Rule Failure if wrong
Same type FIFO source → FIFO DLQ; standard → standard Redrive policy rejected / no move
Same account + region DLQ co-located with source ARN invalid for redrive
Retention ≥ source DLQ retention (ideally 14 d) ≥ source Moved messages expire early (silent loss)
Encryption compatible Consumers hold kms:Decrypt on the CMK KMS.AccessDeniedException reading DLQ
redrive-allow-policy Allows the source (allowAll/byQueue) Redrive back to source blocked
Depth alarm CloudWatch alarm on DLQ Visible > 0 Poison sits unnoticed

Lambda event-source mapping: the whole-batch reprocess trap

When a Lambda function consumes an SQS queue, it does not call ReceiveMessage itself — an AWS-managed event-source mapping (ESM) polls the queue and invokes your function with a batch of up to 10 messages (up to 10,000 for standard queues with a batching window). Here is the trap that duplicates data in production: by default, the ESM treats the entire batch as one unit. If your function throws — even because of one bad message — none of the batch is deleted, so all ten become visible again and the whole batch is redelivered. The nine good messages are reprocessed, and if their side effects are not idempotent, they duplicate.

The fix is partial batch response: set FunctionResponseTypes = ReportBatchItemFailures on the ESM and have your function return the message IDs that failed. SQS then deletes the successes and redelivers only the failures.

Behaviour Without ReportBatchItemFailures With ReportBatchItemFailures
One message in a batch of 10 fails All 10 redelivered and reprocessed Only the 1 failed message redelivered
The 9 good messages Reprocessed (duplicate side effects) Deleted, not reprocessed
Poison message Eventually hits maxReceiveCount → DLQ Same — only it is retried
Return shape Throw / return anything {"batchItemFailures":[{"itemIdentifier":"<msgId>"}]}
Empty batchItemFailures n/a Whole batch treated as success

There is one dangerous subtlety: if your function returns an empty batchItemFailures list while some messages actually failed, Lambda treats the whole batch as successful and deletes everything — losing the failures. Report accurately, and if the function throws entirely, all messages are retried (safe default).

Every ESM knob that shapes SQS processing, with the value that matters:

ESM setting What it does Default Set it to…
FunctionResponseTypes ReportBatchItemFailures for partial batch off on — the single most important SQS ESM setting
BatchSize Messages per invoke 10 up to 10,000 (standard, needs a window); FIFO ≤ 10
MaximumBatchingWindowInSeconds Wait to fill a batch 0 1–20 for larger batches / efficiency
ScalingConfig.MaximumConcurrency Cap concurrent invocations for this ESM none 2–1,000 — protect a fragile downstream
FilterCriteria Drop non-matching messages before invoke none Filter server-side to cut invocations
Queue VisibilityTimeout Hide window during processing 30 s ≥ 6 × function timeout (AWS rule)
Function Timeout Max handler runtime 3 s Set below (visibility ÷ 6)

The visibility ≥ 6× function-timeout rule exists because the ESM may retry and batch: if visibility is too low relative to the function timeout, a message can reappear and be picked up by a second poller while the first is still running — reintroducing the very duplicate you were avoiding.

For scaling, the ESM polls with a small number of concurrent connections and automatically increases them as the backlog grows, up to your function’s concurrency ceiling or the ESM’s maximumConcurrency. When a downstream (a database, a rate-limited API) cannot absorb full fan-out, set maximumConcurrency to cap it — this is cleaner than reserved concurrency because it also lets other functions share the account pool.

# Wire an SQS ESM with partial-batch response and a concurrency cap
aws lambda create-event-source-mapping --function-name order-consumer \
  --event-source-arn "$Q_ARN" --batch-size 10 \
  --function-response-types ReportBatchItemFailures \
  --scaling-config MaximumConcurrency=20
resource "aws_lambda_event_source_mapping" "orders" {
  event_source_arn                   = aws_sqs_queue.orders.arn
  function_name                      = aws_lambda_function.order_consumer.arn
  batch_size                         = 10
  maximum_batching_window_in_seconds = 5
  function_response_types            = ["ReportBatchItemFailures"]
  scaling_config { maximum_concurrency = 20 }
}
# Handler return shape — report ONLY the failures so the rest are deleted
def handler(event, context):
    failures = []
    for record in event["Records"]:
        try:
            process(record)                       # your logic
        except Exception:
            failures.append({"itemIdentifier": record["messageId"]})
    return {"batchItemFailures": failures}

FIFO stuck: group blocks and dedup swallows

FIFO queues buy you strict ordering, and the price is a failure mode standard queues do not have. Ordering is guaranteed per message group (MessageGroupId), and to preserve it, SQS delivers only one un-acked message per group at a time: while a message from group A is in-flight, no other message in group A is delivered until that one is deleted or its visibility timeout expires. So if the head message of a group is a poison message, or a consumer receives it and crashes, the entire group is blocked — every message behind it waits, and ApproximateAgeOfOldestMessage climbs while other groups flow normally.

FIFO failure What happens Confirm Fix
Group blocked by poison head One un-processable message halts its whole group AgeOfOldest climbs; one group stalled, others fine DLQ the poison (redrive policy) so the group advances
Consumer crashed holding the head Group frozen until visibility expires NotVisible for that group; consumer logs stop Shorter visibility / heartbeat; fix the crash
Dedup swallow Resend within 5 min with same dedup ID is accepted but not delivered NumberOfMessagesSent doesn’t rise; producer got a 200 Unique MessageDeduplicationId per distinct message
Too few groups All messages in one group → no parallelism, one stall halts everything Throughput capped; one blocked group = total stall Spread across many MessageGroupIds
In-flight 20k reached FIFO in-flight cap hit sooner than standard OverLimit; NotVisible ≈ 20,000 Delete faster; unblock groups

The deduplication swallow is the sneakiest FIFO bug. A FIFO queue deduplicates messages within a 5-minute window by MessageDeduplicationId (or, with ContentBasedDeduplication, by a SHA-256 of the body). If you resend a message with the same dedup ID inside that window — or if content-based dedup is on and two legitimately-identical messages are sent — the second SendMessage succeeds (returns a MessageId, HTTP 200) but the message is not enqueued. The producer believes it sent; the consumer never sees it. The symptom is “we definitely sent it, and it’s gone,” with no error anywhere.

Dedup setting Behaviour When it bites
Explicit MessageDeduplicationId You control the dedup key Reusing an ID for distinct messages → swallow
ContentBasedDeduplication = true Dedup on body hash Two legitimately-identical bodies → second swallowed
5-minute window Dedup only within 5 min Resend after 5 min is delivered (may duplicate)
Fix Unique ID per distinct message Use a business key + timestamp, not the body

FIFO DLQs work the same way as standard — but the DLQ must itself be FIFO, and redriving a group preserves its ordering. Because a poison head blocks a group, a redrive policy on a FIFO queue is more important than on a standard one: without it, one bad message halts a group indefinitely (until retention).

Redrive from the DLQ back to the source

Once you have fixed the consumer bug or the poison-message cause, the messages sitting in the DLQ need to go back to the source queue to be reprocessed. This is DLQ redrive. The modern, first-class way is the message move task API (start-message-move-task), which streams messages from the DLQ (source of the move) back to their original source queue — no custom script, throttleable, cancellable.

Redrive operation Command Notes
Start redrive aws sqs start-message-move-task --source-arn <DLQ_ARN> Destination defaults to the original source queue
Redrive to a specific queue add --destination-arn <ARN> Send elsewhere (e.g. a re-processing queue)
Throttle the redrive add --max-number-of-messages-per-second N Protect a fragile consumer
List / monitor tasks aws sqs list-message-move-tasks --source-arn <DLQ_ARN> Status, moved count, failure reason
Cancel aws sqs cancel-message-move-task --task-handle <HANDLE> Stop a running redrive

The DLQ must permit the redrive: its redrive-allow-policy must allow the destination (source) queue — allowAll, or byQueue with the source ARN listed. If it is denyAll, the task fails.

# Redrive all messages from the DLQ back to their original source, slowly
DLQ_ARN=$(aws sqs get-queue-attributes --queue-url "$DLQ_URL" \
  --attribute-names QueueArn --query Attributes.QueueArn --output text)
aws sqs start-message-move-task --source-arn "$DLQ_ARN" \
  --max-number-of-messages-per-second 50

# Check progress
aws sqs list-message-move-tasks --source-arn "$DLQ_ARN" \
  --query 'Results[0].[Status,ApproximateNumberOfMessagesMoved]'

Before message move tasks existed, redrive was a manual loop — receive from the DLQ, send to the source, delete from the DLQ — and you will still see it in older runbooks. It works, but it is slow, easy to get wrong (a crash mid-loop can duplicate or drop), and has no built-in throttle. Prefer the task API. Whichever you use, fix the root cause first — redriving into a still-broken consumer just refills the DLQ.

Redrive approach Pros Cons
start-message-move-task Managed, throttleable, cancellable, no code Region/account-local; one active task per source
Console “DLQ redrive” One click; good for ad-hoc Manual; not for automation
Manual receive→send→delete loop Full control; custom transforms Slow, error-prone, no built-in throttle
Redrive to a different queue Isolate for inspection/replay Extra queue to manage

The metrics that confirm each class

You never fix an SQS problem you have not confirmed on a metric first. These are the ones that matter and — crucially — which class each one confirms. All are in the AWS/SQS namespace, dimension QueueName.

Metric Confirms Read it as
ApproximateNumberOfMessagesVisible Backlog / DLQ depth Rising on source = consumers behind; rising on DLQ = poison caught
ApproximateNumberOfMessagesNotVisible In-flight (received, not deleted) High with no progress = stuck; near 120k/20k = OverLimit imminent
ApproximateNumberOfMessagesDelayed Delayed, not yet available Unexpectedly high = a delay/timer you forgot
ApproximateAgeOfOldestMessage The key stuck/backlog signal Climbing = falling behind; near retention = silent loss risk
NumberOfMessagesSent Producer health Flat when you expect sends = dedup swallow or producer down
NumberOfMessagesReceived Consumer poll activity Zero with a backlog = consumer stopped / no permission
NumberOfMessagesDeleted Successful processing Received ≫ Deleted = failing to delete → redeliveries
NumberOfEmptyReceives Short-poll waste High = switch to long polling (cost + noise)
SentMessageSize Payload size Near 256 KB = move payloads to S3 (extended client)
DLQ ApproximateNumberOfMessagesVisible Poison caught > 0 should page you — alarm on this
# Is it a backlog or a stuck queue? Pull the four state numbers.
aws cloudwatch get-metric-statistics --namespace AWS/SQS \
  --metric-name ApproximateAgeOfOldestMessage \
  --dimensions Name=QueueName,Value=orders \
  --start-time "$(date -u -v-3H +%FT%TZ)" --end-time "$(date -u +%FT%TZ)" \
  --period 300 --statistics Maximum

# Alarm the DLQ so one poison message pages you
aws cloudwatch put-metric-alarm --alarm-name orders-dlq-not-empty \
  --namespace AWS/SQS --metric-name ApproximateNumberOfMessagesVisible \
  --dimensions Name=QueueName,Value=orders-dlq \
  --statistic Maximum --period 60 --evaluation-periods 1 \
  --threshold 0 --comparison-operator GreaterThanThreshold

A fast triage decision table for the first 60 seconds of an incident:

If you see… It’s probably… Do this first
Same side effect twice, ReceiveCount > 1 Visibility < runtime Raise visibility; add idempotency
NotVisible high, Visible ≈ 0, no progress Stuck / crashed consumer Lower visibility / heartbeat; fix crash
OverLimit on receive, NotVisible ≈ 120k/20k In-flight cap Delete faster; more consumers
DLQ depth climbing Poison caught (good) Inspect, fix consumer, redrive
Poison loops, DLQ empty Redrive misconfigured Check RedrivePolicy ARN + type
9 good records reprocess with 1 bad No partial-batch response ReportBatchItemFailures
One FIFO group stalls, others fine Poison head blocks the group DLQ the head; spread groups
“Sent it, it’s gone,” NumberOfMessagesSent flat FIFO dedup swallow Unique dedup IDs
AgeOfOldest near retention, count dropping Silent loss Raise retention; add consumers; alarm
KMS.AccessDeniedException Encryption/permission Grant kms:Decrypt/GenerateDataKey

Architecture at a glance

The diagram traces the real path a message takes and pins each failure class to the exact hop where it bites. Read left to right: a producer sends a message (up to 256 KB, and for FIFO with a MessageGroupId and dedup ID) into a source queue, where it lives in the visibility-timeout state machine under the in-flight cap (120,000 standard / 20,000 FIFO); a Lambda consumer (via an event-source mapping) receives it in a batch and is retried until maxReceiveCount; a message that keeps failing is moved to the DLQ, from which — once the bug is fixed — a redrive task moves it back to source; and every state is confirmed from CloudWatch. Six numbered badges mark the six failure classes — duplicate, stuck/in-flight, FIFO group block, whole-batch reprocess, poison-to-DLQ (and not-catching), and redrive — and the legend narrates each as symptom · confirm · fix.

Amazon SQS message-processing failure map showing a producer sending messages up to 256 KB with a FIFO MessageGroupId and 5-minute deduplication into a source queue governed by a 30-second visibility timeout, a 4-day retention and a hard in-flight cap of 120,000 messages for standard queues or 20,000 for FIFO that returns OverLimit, flowing into a Lambda event-source-mapping consumer that receives batches of ten and is retried until maxReceiveCount before a poison message is moved to a dead-letter queue with 14-day retention, from which a start-message-move-task redrive returns messages to the source once the consumer is fixed, with the whole path observed by CloudWatch metrics ApproximateAgeOfOldestMessage and ApproximateNumberOfMessagesNotVisible, and six numbered badges marking duplicate processing at the source queue, the in-flight OverLimit cap, the FIFO group block at the producer group, the whole-batch reprocess without ReportBatchItemFailures at the Lambda consumer, poison-to-DLQ and DLQ-not-catching at the dead-letter queue, and the redrive-and-confirm step

Real-world scenario

ShipRight, a mid-size logistics SaaS on ap-south-1, ran an order-fulfilment pipeline on SQS: a standard orders queue feeding a Lambda consumer that called a warehouse API and wrote to DynamoDB, and a FIFO label-print.fifo queue (grouped by warehouse) driving a label printer that had to print strictly in sequence. Over one peak week all three of the failures in this article showed up, each looking at first like “SQS is broken.”

The duplicate-shipments page came first. Support reported customers getting two shipments for one order. Queue depth looked healthy, so the team almost blamed the warehouse API — until they pulled ApproximateReceiveCount on the messages and saw values of 2 and 3. The consumer’s p99 processing time had crept to 42 seconds after a new validation step, but the queue’s VisibilityTimeout was still the default 30 seconds. Every slow message became visible again mid-processing and a second Lambda invocation grabbed it. The immediate fix was VisibilityTimeout=240 (comfortably above p99) and the ESM’s function timeout capped at 30 s; the durable fix was a conditional PutItem on orderId so a duplicate delivery became a no-op. Duplicates went to zero.

The stuck-and-then-reprocessing incident was subtler. A deploy shipped a bug: on a specific malformed address the handler threw. Because they had never set ReportBatchItemFailures, every batch of ten that contained one bad address failed entirely, so the nine good orders in each batch were reprocessed on every retry — and, because the visibility timeout was now 240 seconds, ApproximateNumberOfMessagesNotVisible ballooned toward the 120,000 in-flight cap as slow retries piled up, and new receives started returning OverLimit. The fix was two-part: add function_response_types = ["ReportBatchItemFailures"] and return only the failed IDs (so the nine good orders were deleted and only the bad one retried), and add a redrive policy with maxReceiveCount = 5 pointing at an orders-dlq (retention 14 days) so the poison address dead-lettered instead of looping. In-flight drained within minutes; the DLQ depth alarm fired with exactly the bad messages.

The FIFO stall was the one nobody saw for six hours. Labels for one warehouse simply stopped printing while every other warehouse was fine. ApproximateAgeOfOldestMessage on label-print.fifo had climbed past five hours, but overall throughput looked normal. The cause: a single label message for warehouse BLR-1 had a null SKU; the consumer threw on it, and because FIFO delivers only one un-acked message per group at a time, that one message blocked the entire BLR-1 group — every label behind it waited. There was no DLQ on the FIFO queue, so it would have blocked until retention. The fix was a FIFO DLQ (label-print-dlq.fifo) with maxReceiveCount = 3, which let the poison message dead-letter after three tries so the group advanced, plus a defensive check on SKU. They redrove the DLQ back after fixing the producer’s validation. The runbook line the team wrote afterward: read ApproximateReceiveCount and AgeOfOldest before you touch anything — the same “it’s broken” is three different fixes.

Advantages and disadvantages

Understanding SQS’s message-processing model is not optional overhead; it is the difference between a queue being a shock absorber and being a silent corruption engine. The same properties that make it durable create the specific traps above.

Advantages (when you operate it right) Disadvantages (the traps to manage)
Fully managed, effectively unlimited standard throughput At-least-once means duplicates are guaranteed, not rare
Visibility timeout gives automatic retry for free Too-short visibility duplicates; too-long visibility stalls
DLQ + redrive isolates and replays poison messages A misconfigured redrive silently catches nothing
FIFO gives strict ordering + dedup One poison message blocks a whole FIFO group
Decouples producer and consumer; absorbs spikes Retention expiry is silent data loss with no error
Native Lambda integration with partial-batch response Without ReportBatchItemFailures, one bad record duplicates the batch
Approximate metrics for depth, age, in-flight Metrics are approximate and eventually consistent

The advantages dominate for spiky, asynchronous, decoupled work — order pipelines, notifications, ETL steps, buffering in front of a fragile downstream. The disadvantages dominate when you treat a queue like a database or assume delivery is exactly-once and in-order for free: any workload that cannot tolerate a duplicate without idempotency, any FIFO workload that crams everything into one message group, and any pipeline without a DLQ and a retention alarm. The skill is configuring the guardrails — visibility sized to runtime, a DLQ with longer retention than the source, partial-batch responses, idempotency, and depth/age alarms — before the incident, not during it.

Hands-on lab

You will reproduce and fix the core SQS failures on a free-tier-friendly account in ap-south-1: build a source queue + DLQ + a Lambda consumer that fails on a poison message, watch the poison message hit the DLQ, add ReportBatchItemFailures to stop the whole batch reprocessing, then redrive the DLQ back to the source. Everything here is within the SQS and Lambda free tiers (a few pennies of CloudWatch at most); teardown is at the end. Run in CloudShell (Bash) where aws is pre-authenticated.

Step 0 — Variables and an execution role.

export AWS_DEFAULT_REGION=ap-south-1
ACCT=$(aws sts get-caller-identity --query Account --output text)
ROLE_ARN=$(aws iam create-role --role-name sqs-lab-role \
  --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}' \
  --query Role.Arn --output text)
aws iam attach-role-policy --role-name sqs-lab-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam attach-role-policy --role-name sqs-lab-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole
sleep 10   # let the role propagate

Step 1 — Create the DLQ, then the source queue with a redrive policy. The DLQ gets 14-day retention (longer than the source), and the source dead-letters after 3 receives.

DLQ_URL=$(aws sqs create-queue --queue-name lab-dlq \
  --attributes MessageRetentionPeriod=1209600 --query QueueUrl --output text)
DLQ_ARN=$(aws sqs get-queue-attributes --queue-url "$DLQ_URL" \
  --attribute-names QueueArn --query Attributes.QueueArn --output text)

SRC_URL=$(aws sqs create-queue --queue-name lab-src --attributes \
  "{\"VisibilityTimeout\":\"90\",\"MessageRetentionPeriod\":\"345600\",\"RedrivePolicy\":\"{\\\"deadLetterTargetArn\\\":\\\"$DLQ_ARN\\\",\\\"maxReceiveCount\\\":\\\"3\\\"}\"}" \
  --query QueueUrl --output text)
SRC_ARN=$(aws sqs get-queue-attributes --queue-url "$SRC_URL" \
  --attribute-names QueueArn --query Attributes.QueueArn --output text)

# Let the DLQ accept a redrive back from the source
aws sqs set-queue-attributes --queue-url "$DLQ_URL" --attributes \
  "{\"RedriveAllowPolicy\":\"{\\\"redrivePermission\\\":\\\"byQueue\\\",\\\"sourceQueueArns\\\":[\\\"$SRC_ARN\\\"]}\"}"

Expected: three queue URLs and ARNs. Confirm the redrive policy is attached:

aws sqs get-queue-attributes --queue-url "$SRC_URL" \
  --attribute-names RedrivePolicy --query Attributes.RedrivePolicy --output text
# -> {"deadLetterTargetArn":"arn:aws:sqs:ap-south-1:...:lab-dlq","maxReceiveCount":"3"}

Step 2 — Deploy a consumer that throws on a poison message, WITHOUT partial-batch response. This reproduces the whole-batch reprocess.

mkdir -p fn && cat > fn/app.py <<'PY'
def handler(event, context):
    for r in event["Records"]:
        print("processing", r["messageId"], r["body"])
        if "POISON" in r["body"]:
            raise Exception("poison message")   # fails the WHOLE batch
    return {"ok": True}
PY
( cd fn && zip -q ../fn.zip app.py )
aws lambda create-function --function-name lab-consumer \
  --runtime python3.12 --handler app.handler --role "$ROLE_ARN" \
  --zip-file fileb://fn.zip --timeout 10
# Note: visibility (90s) is >= 6 x function timeout (10s)
ESM_UUID=$(aws lambda create-event-source-mapping --function-name lab-consumer \
  --event-source-arn "$SRC_ARN" --batch-size 10 \
  --query UUID --output text)

Send nine good messages and one poison, then watch the DLQ:

for i in $(seq 1 9); do aws sqs send-message --queue-url "$SRC_URL" --message-body "good-$i" >/dev/null; done
aws sqs send-message --queue-url "$SRC_URL" --message-body "POISON" >/dev/null
sleep 120   # let it retry to maxReceiveCount (3)
aws sqs get-queue-attributes --queue-url "$DLQ_URL" \
  --attribute-names ApproximateNumberOfMessages

Expected: the POISON message lands in lab-dlq after 3 receives (ApproximateNumberOfMessages = 1). Crucially, check the function’s CloudWatch logs — you will see the nine good messages processed multiple times, because every batch containing the poison message failed entirely and was redelivered. That is the whole-batch reprocess bug.

Step 3 — Fix it with ReportBatchItemFailures. Change the handler to report only the failed message, and enable partial-batch response on the ESM.

cat > fn/app.py <<'PY'
def handler(event, context):
    failures = []
    for r in event["Records"]:
        print("processing", r["messageId"], r["body"])
        if "POISON" in r["body"]:
            failures.append({"itemIdentifier": r["messageId"]})  # ONLY this one
    return {"batchItemFailures": failures}
PY
( cd fn && zip -q ../fn.zip app.py )
aws lambda update-function-code --function-name lab-consumer --zip-file fileb://fn.zip
aws lambda update-event-source-mapping --uuid "$ESM_UUID" \
  --function-response-types ReportBatchItemFailures
sleep 15

Send another mixed batch and confirm the good ones are processed exactly once:

aws sqs purge-queue --queue-url "$SRC_URL"; sleep 60   # purge takes up to 60s
for i in $(seq 1 9); do aws sqs send-message --queue-url "$SRC_URL" --message-body "good2-$i" >/dev/null; done
aws sqs send-message --queue-url "$SRC_URL" --message-body "POISON2" >/dev/null
sleep 120
aws sqs get-queue-attributes --queue-url "$DLQ_URL" --attribute-names ApproximateNumberOfMessages

Expected: now only the POISON2 message reaches the DLQ, and the logs show each good2-* message processed once — the successes were deleted, and only the failure was retried. That is the difference ReportBatchItemFailures makes.

Step 4 — Fix the consumer and redrive the DLQ back to source. Imagine you have shipped a fix for the poison shape; now replay the dead-lettered messages.

# Redrive everything in the DLQ back to its original source queue
TASK=$(aws sqs start-message-move-task --source-arn "$DLQ_ARN" \
  --query TaskHandle --output text)
sleep 10
aws sqs list-message-move-tasks --source-arn "$DLQ_ARN" \
  --query 'Results[0].[Status,ApproximateNumberOfMessagesMoved]' --output text

Expected: the move task reports COMPLETED (or RUNNING then COMPLETED) and a moved count matching the DLQ depth. The messages are back on lab-src for reprocessing. (In a real incident you would have fixed the handler so they now succeed.)

Validation checklist. You reproduced and fixed the core classes and confirmed each from its own fingerprint:

Step Behaviour reproduced The fingerprint The fix you applied
2 Whole-batch reprocess 9 good messages processed multiple times in logs — (the bug)
2 Poison → DLQ ApproximateNumberOfMessages = 1 on the DLQ Redrive policy, maxReceiveCount = 3
3 Partial-batch success Good messages processed once; only poison retried ReportBatchItemFailures + return failed IDs
4 Redrive from DLQ Move task COMPLETED, messages back on source start-message-move-task

Teardown (⚠️ do this — the queues and Lambda are tiny but real).

aws lambda delete-event-source-mapping --uuid "$ESM_UUID"
aws lambda delete-function --function-name lab-consumer
aws sqs delete-queue --queue-url "$SRC_URL"
aws sqs delete-queue --queue-url "$DLQ_URL"
for p in AWSLambdaBasicExecutionRole AWSLambdaSQSQueueExecutionRole; do
  aws iam detach-role-policy --role-name sqs-lab-role \
    --policy-arn arn:aws:iam::aws:policy/service-role/$p; done
aws iam delete-role --role-name sqs-lab-role

Cost note. Everything here is within the SQS free tier (1M requests/month) and the Lambda free tier; a few CloudWatch metrics may cost pennies. Deleting the resources above stops any lingering cost. Note that purge-queue can be called only once every 60 seconds per queue.

Common mistakes & troubleshooting

This is the playbook — the part you bookmark. First the scannable master table you can read at 3 a.m., then the error/status reference, then the three nastiest failures in full. Every row is a real failure with the exact symptom, the class it belongs to, the root cause, the precise command or metric to confirm, and the fix.

# Symptom Category Root cause Confirm (exact cmd / metric) Fix
1 Same side effect happens twice Duplicate Visibility timeout < processing time ApproximateReceiveCount > 1 on the message Raise VisibilityTimeout above p99; idempotency key
2 Duplicates even with correct visibility Duplicate At-least-once redelivery (inherent) Occasional dup, no timeout issue Idempotency (conditional write on a business key)
3 NotVisible high, Visible ≈ 0, no progress Stuck / invisible Consumers received then crashed holding messages ApproximateNumberOfMessagesNotVisible high, logs stopped Lower visibility / heartbeat; fix crash; delete on success
4 ReceiveMessage returns OverLimit In-flight cap 120k (std) / 20k (FIFO) in-flight reached NotVisible ≈ 120,000 / 20,000 Delete faster; more consumers; shorter visibility
5 DLQ depth climbing steadily Poison → DLQ A payload/bug fails every retry DLQ ApproximateNumberOfMessagesVisible > 0 Inspect DLQ; fix consumer; redrive after fix
6 Poison loops forever, DLQ empty DLQ-not-catching No / wrong RedrivePolicy get-queue-attributes shows no or wrong ARN Add correct RedrivePolicy; matching type
7 Message moved to DLQ then vanishes DLQ-not-catching DLQ retention < source (expiry by original timestamp) DLQ MessageRetentionPeriod < source DLQ retention = 14 days (≥ source)
8 9 good records reprocess with 1 bad Whole-batch reprocess No ReportBatchItemFailures on the ESM Logs show good messages repeated Enable partial-batch response; return failed IDs
9 Lambda ESM never invokes; backlog grows Permission Role lacks sqs:ReceiveMessage/DeleteMessage ESM State = Disabled; LastProcessingResult Add SQS actions to the execution role
10 One FIFO group stalls; others fine FIFO group block Poison head; only 1 un-acked/group delivered AgeOfOldest climbs; one group’s messages NotVisible DLQ the head (redrive policy); spread groups
11 “Sent it,” but consumer never sees it FIFO dedup swallow Reused MessageDeduplicationId within 5 min NumberOfMessagesSent flat; producer got a 200 Unique dedup ID per distinct message
12 Message gone, DLQ empty, no error Silent loss Retention expired before processing ApproximateAgeOfOldestMessage was near retention Raise retention; add consumers; alarm on age
13 KMS.AccessDeniedException on send/receive KMS / permission Principal lacks key perms on the CMK Error names the key ARN Grant kms:Decrypt + kms:GenerateDataKey; key policy
14 MessageNotInflight on delete/visibility Consumer bug Receipt handle expired (visibility passed) Delete after visibility timeout elapsed Delete within visibility; extend with heartbeat
15 ReceiptHandleIsInvalid Consumer bug Stale/reused receipt handle Handle from an old receive Use the latest receipt handle per receive
16 Send rejected, message too large Producer limit Body > 256 KB SentMessageSize near/over 256 KB SQS Extended Client + S3 for large payloads
17 FIFO redrive policy rejected DLQ-not-catching Standard DLQ on a FIFO source (type mismatch) SetQueueAttributes error Use a FIFO DLQ (.fifo) for a FIFO source
18 High cost + noisy empty polls Efficiency Short polling (WaitTimeSeconds = 0) NumberOfEmptyReceives high Long polling: ReceiveMessageWaitTimeSeconds = 20
19 Backlog grows though consumers run Under-provisioned Delete rate < send rate NumberOfMessagesDeletedNumberOfMessagesSent Scale consumers; batch; raise maximumConcurrency
20 Downstream DB overwhelmed by consumers Scale-out stampede ESM fan-out exceeds downstream capacity High concurrent invocations Set ESM maximumConcurrency; RDS Proxy

Now the error/status reference — the exact strings SQS and its consumers throw, and what each one means:

Error / string Where Meaning Fix
AWS.SimpleQueueService.OverLimit ReceiveMessage In-flight cap (120k/20k) reached Delete faster; more consumers; shorter visibility
AWS.SimpleQueueService.NonExistentQueue any Queue URL/ARN wrong or deleted Fix the URL/ARN; recreate if needed
MessageNotInflight DeleteMessage / ChangeMessageVisibility Visibility already expired; message no longer in-flight Complete work within visibility; heartbeat
ReceiptHandleIsInvalid delete / visibility Stale or malformed receipt handle Use the handle from the latest receive
KMS.AccessDeniedException send / receive No permission on the SSE-KMS key kms:Decrypt + kms:GenerateDataKey; key policy
InvalidParameterValue (dedup) SendMessage (FIFO) Missing MessageGroupId or dedup config Provide group ID; set dedup ID or content-based
MessageTooLong / 256 KB SendMessage Body exceeds 262,144 bytes Extended client + S3
PurgeQueueInProgress PurgeQueue Purge called within 60 s of a prior purge Wait 60 seconds between purges
QueueDeletedRecently CreateQueue Reusing a name within 60 s of deletion Wait 60 seconds before recreating
RequestThrottled / FIFO TPS SendMessage (FIFO) 300 msg/s (3,000 batched) exceeded Batch; enable high-throughput FIFO

The three that cause the most damage, expanded:

A. The whole-batch reprocess (silent duplication of the good records). A Lambda consumes an SQS queue and one message in a batch of ten is poison. Because the default event-source mapping treats the batch as a unit, the function throwing on the one bad message means none of the batch is deleted, so all ten reappear after the visibility timeout and the whole batch is reprocessed — the nine good messages run again, and if their side effects are not idempotent, they duplicate on every retry until the poison message finally hits maxReceiveCount. Confirm: the function’s CloudWatch logs show the same good messageIds processed repeatedly, and the queue’s ApproximateNumberOfMessagesNotVisible climbs as retries pile up (which, with a long visibility timeout, can march toward the 120,000 in-flight cap and OverLimit). Fix: set FunctionResponseTypes = ReportBatchItemFailures on the ESM and return {"batchItemFailures":[{"itemIdentifier": id}]} for exactly the messages that failed — SQS then deletes the successes and redelivers only the failures. Make the handler idempotent as a belt-and-braces measure, and ensure the queue’s visibility timeout is ≥ 6× the function timeout so a slow retry cannot double-deliver.

B. The FIFO group block (one message halts an entire ordered stream). A FIFO queue stops making progress for one MessageGroupId while every other group flows normally, and nobody notices until ApproximateAgeOfOldestMessage is measured in hours. FIFO preserves order by delivering only one un-acked message per group at a time, so a single poison message at the head of a group — a null field, an unexpected shape — that throws on every retry blocks its whole group; every message behind it waits, up to the retention period, because the queue will not skip ahead and break ordering. Confirm: ApproximateAgeOfOldestMessage climbs steadily while throughput on other groups is fine; inspect the stalled group and you will see the same head message received over and over (ApproximateReceiveCount rising). Fix: attach a FIFO DLQ with a RedrivePolicy and a sane maxReceiveCount (e.g. 3) so the poison head dead-letters after a few tries and the group advances; make the handler defensive on the shapes you actually see; and design for many message groups (e.g. per-entity, not one global group) so a single bad message can never halt the entire stream. Redrive the DLQ back once the producer or consumer is fixed.

C. The silent loss at retention (data gone with no DLQ and no error). Messages simply disappear — no error, no DLQ entry, no trace — because they aged past the retention period before anyone processed them. This happens when the consumer is down or badly under-provisioned and the backlog’s ApproximateAgeOfOldestMessage reaches MessageRetentionPeriod (4 days by default), at which point SQS deletes the messages. It also happens with a “working” DLQ when the DLQ’s retention is shorter than the source’s: because expiry is measured from a message’s original enqueue timestamp, a message that spent days in the source and is then moved to a short-retention DLQ is already past the DLQ’s retention and is deleted almost immediately — caught and lost in one step. Confirm: ApproximateAgeOfOldestMessage trending toward MessageRetentionPeriod on the source, or a DLQ whose MessageRetentionPeriod is less than the source’s, with counts that drop without corresponding deletes. Fix: set retention generously (up to 14 days) for critical queues, set every DLQ’s retention to the maximum and never below the source, alarm on ApproximateAgeOfOldestMessage well before it reaches retention, and make sure a consumer outage pages you long before four days pass.

Best practices

Security notes

Cost & sizing

SQS bills on requests — each SendMessage, ReceiveMessage (each poll, even empty), DeleteMessage, and each ChangeMessageVisibility is an API call, and a 64 KB chunk counts as one request (so a 256 KB message = 4 requests). There is no charge for the queue itself, for storage within retention, or for the DLQ beyond its own requests. The free tier is 1 million requests per month, after which standard queues are inexpensive and FIFO slightly more.

Cost driver How it’s billed Right-size by Rough figure
Requests (standard) Per API call, 64 KB = 1 request Long polling; batch sends/receives (10×) ~$0.40 per million after free tier
Requests (FIFO) Per API call Batch; high-throughput mode ~$0.50 per million
Empty receives Each poll is a request Long polling (WaitTimeSeconds = 20) Short polling can multiply cost
Payload > 64 KB Rounded up per 64 KB Keep messages small; S3 for large blobs A 256 KB message = 4 requests
KMS (SSE-KMS) KMS API calls for data keys Data-key reuse (SQS caches) SSE-SQS is free; SSE-KMS adds KMS cost
Data transfer Out to internet / cross-region Keep consumers in-region; VPC endpoints In-region is free

Sizing rules of thumb: long polling and batching are the two biggest cost levers — a consumer short-polling an idle queue 24/7 can generate millions of empty-receive requests for nothing, while long polling collapses that to near zero. Batch sends and receives in tens to cut request count by up to 10×. For throughput, a standard queue scales effectively without limit, so “sizing” is really about the consumer fleet (or the ESM’s maximumConcurrency) keeping the delete rate above the send rate. In INR terms, most event-driven workloads (a few million to tens of millions of requests, sub-256 KB) run in the low tens to low hundreds of rupees per month before free tier — the expensive mistakes are short polling and oversized payloads, not the queue itself.

Interview & exam questions

Q1. Why does a standard SQS queue deliver duplicates, and how do you make your consumer safe? Standard queues are at-least-once by design — the distributed storage and visibility-timeout redelivery mean a message can be delivered more than once. You do not prevent it; you make processing idempotent (e.g. a conditional PutItem on MessageId or a business key), so a second delivery is a harmless no-op. (SAA-C03, DVA-C02)

Q2. A consumer processes each message twice under load. Walk the diagnosis. Check ApproximateReceiveCount — if it is > 1, the message is being redelivered while still processing, which means the visibility timeout is shorter than the processing time. Raise VisibilityTimeout above p99 (or extend it with ChangeMessageVisibility as a heartbeat) and add idempotency. (DVA-C02)

Q3. What is the in-flight message limit, and what error signals it? A queue can have at most 120,000 in-flight (received-but-not-deleted) messages for standard and 20,000 for FIFO. Exceeding it makes ReceiveMessage return OverLimit (AWS.SimpleQueueService.OverLimit). It is a hard limit; the fix is deleting faster (more/quicker consumers, shorter visibility), not a quota increase. (SAA-C03, SOA-C02)

Q4. A message never reaches the DLQ even though the consumer keeps failing. Name three causes. No RedrivePolicy on the source; a wrong/typo deadLetterTargetArn (or a type mismatch — standard source with a FIFO DLQ); or the message expiring at retention before it hits maxReceiveCount. Also: the consumer deleting the message even on failure, or a Lambda swallowing the exception so no failure is recorded. (DVA-C02, SOA-C02)

Q5. Explain the Lambda whole-batch reprocess trap and its fix. Without ReportBatchItemFailures, a Lambda ESM treats the batch as one unit — if the function throws on one message, none of the batch is deleted, so all of them (including the good ones) are redelivered and reprocessed. Enable ReportBatchItemFailures and return only the failed message IDs so SQS deletes the successes and retries only the failures. (DVA-C02)

Q6. Why can one message stall an entire FIFO queue, and how do you prevent it? FIFO delivers only one un-acked message per message group at a time to preserve order, so a poison head message blocks its whole group until it is deleted or ages out. Prevent it with a FIFO DLQ + maxReceiveCount so poison heads dead-letter, and by spreading messages across many message groups so one bad message can’t halt everything. (SAA-C03, DVA-C02)

Q7. What is the FIFO deduplication “swallow,” and when does it bite? FIFO dedups within a 5-minute window by MessageDeduplicationId (or body hash with content-based dedup). A resend with the same dedup ID inside that window returns success but is not enqueued — the message silently vanishes. It bites when you reuse dedup IDs for distinct messages or send legitimately-identical bodies with content-based dedup on. (DVA-C02)

Q8. What’s the relationship between visibility timeout and a Lambda function’s timeout? AWS recommends the queue’s visibility timeout be at least 6× the function timeout, so that batching and retries can’t make a message visible and re-delivered to a second poller while the first invocation is still running — which would duplicate processing. (DVA-C02, SOA-C02)

Q9. How do you replay dead-lettered messages after fixing the bug? Use a message move task: aws sqs start-message-move-task --source-arn <DLQ_ARN> streams messages back to their original source queue (throttleable with --max-number-of-messages-per-second, cancellable). The DLQ’s redrive-allow-policy must permit the source. Always fix the root cause first, or the DLQ just refills. (SOA-C02, DVA-C02)

Q10. Which metric tells you a queue is heading toward silent data loss, and how do you prevent it? ApproximateAgeOfOldestMessage — when it approaches MessageRetentionPeriod, messages will expire unprocessed and be deleted with no error. Prevent it by raising retention on critical queues, alarming on the age metric well before retention, and keeping every DLQ’s retention at the maximum (≥ source). (SOA-C02)

Q11. Standard vs FIFO — which for an order-processing pipeline that must not double-charge? Either can work, but the deciding factors are ordering and throughput. If strict per-customer ordering matters and volume is within FIFO limits, FIFO (grouped by customer) gives ordering plus 5-minute dedup; otherwise standard with application-level idempotency handles double-charge protection at higher throughput. Idempotency is required either way. (SAA-C03)

Q12. How does short vs long polling affect correctness and cost? Short polling (WaitTimeSeconds = 0) samples a subset of servers, can return empty even when messages exist, and bills every poll — driving up cost and empty receives. Long polling (up to 20 s) waits for a message across all servers, cuts empty receives, and lowers cost and latency. It affects cost and efficiency, not delivery guarantees. (DVA-C02, SOA-C02)

Quick check

  1. Your consumer processes every slow message twice. What single message attribute confirms the cause, and what is the fix?
  2. A queue shows ApproximateNumberOfMessagesNotVisible pinned near 120,000 and ReceiveMessage returns an error. What is the error, and what does it mean?
  3. Poison messages loop forever and your DLQ stays empty. Name two configuration reasons.
  4. What one Lambda ESM setting stops a single bad message from causing the other nine in its batch to reprocess?
  5. One MessageGroupId in a FIFO queue stops advancing while others are fine. What’s happening, and what’s the fix?

Answers

  1. ApproximateReceiveCount > 1 confirms the message is being redelivered mid-processing because the visibility timeout is shorter than the processing time. Raise VisibilityTimeout above p99 (or heartbeat with ChangeMessageVisibility) and make the consumer idempotent.
  2. OverLimit (AWS.SimpleQueueService.OverLimit) — the queue has hit the 120,000 in-flight (not-visible) message cap (20,000 for FIFO). Consumers are receiving far faster than they delete. Delete faster, add consumers, or shorten the visibility timeout.
  3. Any two of: no RedrivePolicy on the source; a wrong/typo deadLetterTargetArn; a type mismatch (standard source, FIFO DLQ); maxReceiveCount so high the message expires at retention first; or the consumer deleting on failure / Lambda swallowing the exception.
  4. ReportBatchItemFailures (partial batch response) — return only the failed message IDs in batchItemFailures so the successes are deleted and only the failure is retried.
  5. A poison head message is blocking that group — FIFO delivers only one un-acked message per group to preserve order, so everything behind it waits. Fix: attach a FIFO DLQ with a RedrivePolicy so the head dead-letters after maxReceiveCount, and spread traffic across many message groups.

Glossary

Term Definition
Visibility timeout The window after a message is received during which it is hidden from other consumers; on expiry (without a delete) it becomes visible again. Default 30 s, max 12 h.
In-flight message A message that has been received but not yet deleted or timed out; counted by ApproximateNumberOfMessagesNotVisible.
In-flight limit The hard cap on in-flight messages — 120,000 (standard) / 20,000 (FIFO); exceeding it returns OverLimit.
At-least-once delivery The standard-queue guarantee that a message is delivered one or more times — duplicates are expected, so consumers must be idempotent.
Dead-letter queue (DLQ) An SQS queue that a source queue’s redrive policy targets; messages that exceed maxReceiveCount are moved there for isolation and inspection.
Redrive policy The source-queue config (deadLetterTargetArn + maxReceiveCount) that sends failing messages to a DLQ.
maxReceiveCount The number of times a message may be received without deletion before it is dead-lettered (1–1,000).
Redrive (message move task) Replaying messages from a DLQ back to the source with start-message-move-task; throttleable and cancellable.
Poison message A message that fails on every processing attempt (bad payload, missing field, or a consumer bug) and eventually dead-letters.
Message group (FIFO) The MessageGroupId that defines an ordered stream; only one un-acked message per group is delivered at a time.
Deduplication (FIFO) Suppression of duplicate sends within a 5-minute window by MessageDeduplicationId or a content hash.
Partial batch response Returning only failed message IDs (ReportBatchItemFailures) from a Lambda so successful records in a batch are deleted.
Event-source mapping (ESM) The Lambda-managed poller that reads batches from SQS and invokes the function.
ApproximateAgeOfOldestMessage The age of the oldest message still in the queue; the key signal for backlog and impending retention-based silent loss.
Retention period How long SQS keeps a message (60 s–14 days, default 4 days), measured from the original enqueue timestamp; expiry is silent loss.

Next steps

AWSSQSDead-Letter QueueFIFOLambdaTroubleshootingCloudWatchMessaging
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading