SQS Messages in Queue but Not Received: Every Fix

Logeshwaran.C

If ReceiveMessage keeps coming back empty even though the Amazon SQS console shows messages sitting in the queue, the fix is almost never on the sending side. It's one of a handful of defaults working exactly as designed against you — a visibility timeout hiding a message another worker already grabbed, short polling that only checks a random slice of SQS's backend servers, or a FIFO message group stuck behind one in-flight message. Amazon SQS is designed to sometimes tell you "no messages" while messages are sitting right there, and the duplicate processing you're probably also fighting isn't a bug either — SQS is allowed by design to deliver a message more than once.

⚡ Quick Answer

Check the queue isn't emptyApproximateNumberOfMessagesVisible

Turn on long pollingWaitTimeSeconds = 20

Check for a stuck message → look at ApproximateNumberOfMessagesNotVisible

If it's a FIFO queue and nothing after the first message ever arrives, jump straight to the message group section — that's almost always it.

Which of these is actually happening to you?

Jake runs a phone repair shop and built a small serverless setup a few months back: when a repair is marked "ready" in his ticketing app, a message lands in an SQS queue, and a Lambda function pulls it off and texts the customer. Cheap, simple, no server to babysit. Then two things started happening in the same week. Some ready-repair texts never went out at all — the queue showed messages, but his polling script kept returning nothing. And a few customers got the "your phone is ready" text twice, ten minutes apart, which is its own kind of embarrassing when the second text arrives while they're already standing at the counter.

Those two symptoms — messages that don't come back, and messages that come back twice — are related, and both are covered in this one page instead of splitting them across two posts. If you're only seeing one of them, use this table to jump to the right cause instead of reading top to bottom.

What you're seeing Likely cause Jump to
Console shows messages, ReceiveMessage returns noneShort polling sampling, or a message in flightPolling / Visibility timeout
Messages appear, then vanish for a while, then reappearVisibility timeout expired mid-processingVisibility timeout
Nothing arrives for the first few minutes after sendingDelivery delay or a message timerDelivery delay
FIFO queue stops returning anything after one messageMessage group ID blockingFIFO groups
Nothing ever arrives, no error eitherWrong queue URL, wrong region, or a silent permissions gapWrong target / Permissions
The same message gets processed more than onceStandard queue's at-least-once delivery modelDuplicates

🙋‍♂️ Jake's Reality Check

"I checked the console and it clearly says three messages are in the queue. Why does my script say zero?"

Because "in the queue" and "available to receive right now" aren't the same thing. A message can be sitting in the queue and still be invisible to you — either because another poll already grabbed it, or because your poll only checked part of SQS's backend and missed it. Neither means the message is lost.

Rule out an actually-empty queue first

Before chasing anything more subtle, confirm the queue actually has messages available. The console's count and a single API call can both lag reality slightly, since SQS is distributed across many servers with no one authoritative counter. Check three CloudWatch metrics together, or read the same values with GetQueueAttributes:

  1. ApproximateNumberOfMessagesVisible — messages available to receive right now.
  2. ApproximateNumberOfMessagesNotVisible — messages currently "in flight" (already picked up, hidden during their visibility timeout).
  3. ApproximateNumberOfMessagesDelayed — messages waiting out a delivery delay before they become visible at all.

If all three read zero for several minutes straight, the queue genuinely is empty and there's nothing to receive — your problem is upstream, on the sending side. If NotVisible is the one showing a number, skip ahead to the visibility timeout section below; that's your answer.

The visibility timeout: SQS's "someone else has it" flag

Every time a consumer receives a message, SQS doesn't remove it — it hides it from every other consumer for a set number of seconds, the visibility timeout, on the assumption that whoever just received it is now processing it. The default is 30 seconds. During that window the message still exists in the queue but isn't visible, so a second ReceiveMessage call won't see it. This is deliberate, and it's the single most common reason people think messages "disappeared." They didn't — they're invisible on purpose, for up to 12 hours if you set it that high, the maximum SQS allows.

Jake's first instinct, when this got explained to him, was to just make the number bigger. "So if I set the visibility timeout to an hour, this whole problem goes away, right?"

"It goes away for the case where processing genuinely takes a while," Ethan said. "It comes right back the moment your function throws an error and never deletes the message — except now you find out about the stuck message an hour later instead of thirty seconds later."

"So that's worse."

"It's worse for noticing a bug. It's better for not accidentally double-processing something because you guessed too short. You want it long enough to cover your genuinely slow case, not long enough to bury your mistakes."

✅ Why this is the one to use

Set the visibility timeout to somewhat longer than your worst-case processing time, not the average. If your function typically finishes in 3 seconds but occasionally takes 40 during a cold start, a 30-second default is going to bite you. A generous timeout costs nothing while the queue is healthy; a timeout that's too short manufactures duplicate work.

Short polling vs. long polling — and why the default is the trap

Here's the counterintuitive part. SQS runs on a fleet of backend servers, and by default — WaitTimeSeconds at 0 — a ReceiveMessage call only checks a weighted random subset of them before answering immediately. If your message is sitting on a server that call didn't check, you get an empty response even though the message is available the whole time. That's short polling, and it's the default.

Long polling fixes it by checking across all servers and waiting — up to 20 seconds — for at least one message before returning empty. Turn it on by setting WaitTimeSeconds above 0, per-request or as the queue's ReceiveMessageWaitTimeSeconds attribute.

Ethan doesn't hedge on this one: "Short polling is the default for a reason nobody loves — it's just what the API defaulted to years ago. I can't think of a production queue where short polling is actually the right choice. Unless you need an immediate synchronous answer for something like a UI check, turn on long polling once and stop thinking about it."

To turn on long polling for an existing queue through the console:

  1. Open the Amazon SQS console and select the queue from the Queues list.
  2. Choose Edit, then scroll to the Configuration section.
  3. Set Receive message wait time to a value between 1 and 20 seconds — 20 is the safe default for most workloads.
  4. Save. Existing consumers using default ReceiveMessage calls (no explicit WaitTimeSeconds override) will now use this value automatically.

⚠️ What this actually breaks

If a caller explicitly passes WaitTimeSeconds: 0 on the request, that overrides the queue-level setting and forces short polling for that call regardless of what you configured on the queue. Setting the queue attribute isn't enough if your SDK code hard-codes a shorter wait time somewhere.

Short polling isn't billed any differently from long polling, so there's no cost reason to avoid it. The only reasons to prefer long polling are fewer empty responses and no missed-message risk — which is to say, every reason.

Delivery delay and message timers

If messages are missing for the first few minutes after sending and then show up fine, check for a delivery delay on the queue, or a per-message timer on the send. Either keeps a message genuinely unreturnable by ReceiveMessage until it expires. The range is 0 to 900 seconds (15 minutes), default 0 — visible as the queue's Delivery delay setting, or the DelaySeconds attribute via GetQueueAttributes. The ApproximateNumberOfMessagesDelayed metric mentioned earlier shows a non-zero count while messages wait this out.

Don't confuse a delivery delay with the visibility timeout — they solve different problems and use different attributes, and it's easy to change the wrong one while debugging under pressure.

The in-flight message limit — the quota nobody checks

Standard queues cap how many messages can be "in flight" — received but not yet deleted or expired — at 120,000 by default. FIFO queues cap it lower. If your consumers are receiving faster than they're deleting, and you've been quietly building up a backlog of unfinished messages for hours or days, you can hit that ceiling.

"That number sounds huge," Jake said. "Why would I ever get anywhere near 120,000?"

"You won't, by having a busy queue," Ethan said. "You'll get there by having a broken one. Something stops deleting messages after processing — a bug, a permissions change, a downstream service that started timing out — and now the same few thousand messages keep expiring back into visibility and getting picked up again instead of leaving. It's not volume that gets you there. It's a leak."

This is a slow-burn cause: it won't show up on day one, only after a backlog of stuck or slow-to-delete messages accumulates. If your ApproximateNumberOfMessagesNotVisible metric is climbing steadily over hours instead of staying flat, that's the tell.

FIFO queues: one stuck message blocks its whole group

If you're on a FIFO queue and messages stop arriving after the first one or two, this is almost certainly it. FIFO queues use a MessageGroupId to guarantee strict order within a group — messages sharing a group ID are never processed in parallel or delivered out of order. So if one message from a group is in flight (received but not yet deleted), no other message from that group can be delivered, even with plenty else sitting in the queue.

If every message uses the same single MessageGroupId — a common default when people first get FIFO working — you've built a single-lane queue: nothing moves until the one message out gets deleted or its timeout expires. Jake hit exactly this switching his notification queue to FIFO for ordering — every text after the first sat there until he deleted it or gave each customer's messages their own group ID.

Ethan has an opinion about how Jake got there in the first place: "FIFO is the right call when order genuinely matters to the business — payments, sequential state changes, that kind of thing. It's the wrong call when someone reaches for it because 'first in, first out' sounds more correct than 'standard.' Most FIFO queues I've seen could have been standard queues with idempotent processing, and the team would have gotten more throughput for free."

✅ Why this is the one to use

Use a distinct MessageGroupId per independent stream of work — per customer, per order, per tenant — not one global ID for the whole queue. That keeps strict ordering where you actually need it without serializing unrelated work behind it.

You might be polling the wrong queue entirely

This sounds too obvious to be worth a section, and it's exactly why it survives so long in real setups. Queue URLs are region-specific and case-sensitive. A queue URL copied from one environment (staging) hard-coded into another (production), or a client configured for the wrong AWS Region, will return empty results forever with zero errors, because as far as that queue is concerned, it genuinely has no messages — because you're never sending any to it. Check that the queue URL your consumer is using matches the queue your producer is sending to, region and account included, before assuming anything more exotic is going on.

When permissions are the problem — and when they aren't

Missing sqs:ReceiveMessage permission usually does not fail silently — it typically throws an explicit AccessDenied error, so if your calls are erroring loudly, this isn't your problem; go back to the visibility timeout and polling sections instead. Two systems can grant or block ReceiveMessage: an IAM identity-based policy on the caller, and a resource-based access policy on the queue itself. Cross-account access specifically requires the queue's own policy to explicitly allow the calling account or role — an IAM policy on the caller's side alone isn't enough.

If the queue uses a customer-managed KMS key for encryption, there's a second layer: the caller also needs kms:GenerateDataKey and kms:Decrypt on that key. A caller with full SQS access but no KMS access will get an explicit KMS-specific AccessDenied error, not a silent empty receive — worth ruling out if you recently encrypted a queue that was working fine before.

Why messages get processed twice — and why that's expected

Here's the part that surprises people most: standard SQS queues guarantee at-least-once delivery, not exactly-once. That's the documented contract, not a bug you're triggering. If a consumer receives a message and doesn't delete it before the visibility timeout expires — processing ran long, the worker crashed, a delete call dropped — the message becomes visible again and can be processed a second time by anyone polling the queue. "Just make the timeout longer" helps but doesn't eliminate it, only makes it rarer. The only reliable fix is idempotent processing: design it so handling the same message twice produces the same result as handling it once.

"The mistake isn't the duplicate message," Ethan said, when Jake asked why this wasn't just something AWS should fix. "The mistake is code that assumes there won't be one. SQS never promised exactly-once on a standard queue. That promise was never made, so it can't be broken."

Making processing idempotent generally comes down to a few concrete steps:

  1. Pick a unique key for the work — the message's MessageId, or a business key like an order or ticket ID.
  2. Before acting (sending the text, charging the card, writing the record), check whether that key has already been handled.
  3. If it has, delete the message and stop — don't repeat the side effect, just acknowledge that it's already done.

For Jake's texting problem, that meant checking "have I already sent a ready-notification for this ticket ID" before sending, instead of trusting that each message would only ever be received once.

🙋‍♂️ Jake's Reality Check

"So Amazon is just... allowed to send my customer two texts? That feels like a bug I should be able to report."

It's not a bug — it's the deal you made when you picked a standard queue. If a workload genuinely can't tolerate any duplicates and can't be made idempotent, that's what FIFO queues with content-based deduplication exist for, though they trade away standard-queue throughput to get it.

FIFO queues reduce this risk with deduplication, either content-based (SQS hashes the message body) or an explicit MessageDeduplicationId you supply, applied over a 5-minute deduplication interval. That's a meaningfully different guarantee than idempotent processing, and it only protects against duplicate sends within that window — it doesn't protect against a message being received twice by two different workers if your delete logic still has a bug in it.

Stop a poison message from looping forever with a dead-letter queue

If a specific message keeps failing processing every time — a malformed payload, an unreachable dependency — it cycles through receive-fail-become-visible-again indefinitely unless you configure a redrive policy. A dead-letter queue (DLQ) is another SQS queue designated as the destination for messages received too many times without being deleted. It's set with two values on the source queue's redrive policy: the DLQ's ARN, and maxReceiveCount — receives allowed before SQS moves the message off instead of back into circulation. Default maxReceiveCount is 10.

To set one up:

  1. Create a second queue to serve as the DLQ — a standard queue's DLQ must also be standard, and a FIFO queue's DLQ must also be FIFO.
  2. On the source queue, open Edit, find the dead-letter queue section, and select the DLQ you created.
  3. Set Maximum receives to a number that gives your consumer a reasonable number of genuine retries — setting it to 1 means a single transient failure exiles a perfectly good message.
  4. On the DLQ itself, check the redrive allow policy if you're locking down which source queues may use it as their DLQ.

One thing worth naming plainly: opening a message in the SQS console to inspect it can itself count as a receive against that message's total, which can push a message into the DLQ faster than you expected purely from debugging it. If you're chasing a DLQ mystery, check the queue's NumberOfMessagesSent metric rather than repeatedly polling it by hand in the console.

If Lambda is your consumer, the rules shift slightly

Everything above still applies, but Lambda's event source mapping adds its own layer — Jake's setup, feeding SQS into Lambda, hits this directly. Lambda polls SQS with long polling and groups messages into batches; default batch size is 10, standard queues allow up to 10,000 with a batching window configured, FIFO caps at 10. Lambda only scales polling when messages are waiting — watch ApproximateNumberOfMessagesVisible to see whether your function isn't being invoked at all versus being invoked and failing.

For duplicates specifically: if your function throws for even one message in a batch, by default Lambda's event source mapping treats the whole batch as failed and every message in it becomes visible again — including the ones that actually succeeded. Ethan calls this out directly: "This is the single most common way I've seen a team accidentally build a duplicate-processing machine and then go blame SQS for it. One bad message in a batch of ten, and now nine good messages get reprocessed too."

The fix is enabling ReportBatchItemFailures in the function's response types, which lets your function tell Lambda exactly which message IDs in the batch failed, so only those get retried instead of the entire batch.

Automating the fix so it doesn't quietly regress

"Can't I just click the box in the console and be done?" Jake asked, after fixing his queue by hand.

"For one queue, sure," Ethan said. "Past a couple, put it in code — otherwise the next queue someone spins up inherits whatever AWS ships by default, which is short polling and a 30-second timeout, and you're back here in six months."

The CLI sets it directly: aws sqs set-queue-attributes --queue-url your-queue-url --attributes ReceiveMessageWaitTimeSeconds=20. The same attribute name also works as a CloudFormation property alongside VisibilityTimeout, MessageRetentionPeriod, and RedrivePolicy — so every setting in this post can be defined once, in version control, instead of re-clicked on every new queue.

Do you need a third-party queue-monitoring tool?

Jake asked about this after his second incident, half-expecting the answer to be "buy a dashboard." It isn't, and Ethan was direct: "For one queue, or a handful, the CloudWatch metrics we've already covered are the whole diagnostic toolkit. A third-party tool doesn't know anything CloudWatch doesn't — it just presents it across more queues and accounts at once."

A third-party tool earns its cost at scale: dozens of queues across multiple accounts, where clicking into each Monitoring tab becomes the actual bottleneck. Short of that, it's solving a problem you don't have yet.

The defaults that cause most of this, in one place

Setting Default Range
Visibility timeout30 seconds0 seconds – 12 hours
Receive message wait time (long polling)0 seconds (short polling)0 – 20 seconds
Delivery delay0 seconds0 – 900 seconds (15 min)
Message retention period4 days60 seconds – 14 days
Maximum message size1,048,576 bytes (1 MiB)1,024 bytes – 1 MiB
DLQ maxReceiveCount10Up to 1,000
In-flight limit (standard queue)120,000 messagesFixed

Edge cases worth knowing about

A few situations that don't fit neatly into the causes above but come up often enough to name:

VPC endpoints. If the queue's access policy restricts access to a specific VPC endpoint (an aws:sourceVpce condition), a consumer calling from outside that VPC — your laptop, a different account, a Lambda function not attached to it — is denied, even with correct IAM permissions.

Messages larger than the limit. A payload over the 1 MiB maximum isn't silently truncated — the send itself is rejected. If your producer swallows that error, it looks exactly like "messages aren't arriving" from the consumer's side.

Retention period expiring. Messages older than the queue's MessageRetentionPeriod (default 4 days, max 14) are deleted automatically, with no error and no DLQ redirect. An oversized backlog will quietly lose its oldest messages rather than erroring.

Checking messages from the console. The console's "Send and receive messages" feature does a real receive under the hood — peeking at messages there can trigger the same visibility timeout and DLQ receive-count behavior a production consumer would.

What to actually watch going forward

Past the initial fix, three CloudWatch metrics catch almost everything in this post before it becomes customer-facing: ApproximateNumberOfMessagesNotVisible trending upward over hours (a deletion bug), NumberOfEmptyReceives spiking (short polling, or a genuinely idle consumer), and ApproximateAgeOfOldestMessage climbing (a backlog heading toward retention expiry).

✅ Why this is the one to use

A CloudWatch alarm on ApproximateAgeOfOldestMessage is the single highest-value thing you can add to a queue you haven't built a DLQ for yet. It tells you a backlog is forming long before messages start expiring off the end of the retention period.

For the pieces this post assumes you already have running — the queue itself, and the Lambda function consuming it — the two related posts below cover the setup end to end.

Frequently asked questions

Why does ReceiveMessage return an empty list even though the console shows messages?

Most often it's short polling — the default when WaitTimeSeconds is 0 — checking only a subset of SQS's backend servers rather than all of them. The second most common cause is that the messages are currently in flight, hidden by another consumer's visibility timeout, and simply not visible to any other receiver right now.

What is the default visibility timeout in SQS?

30 seconds, if you don't set one on the queue or override it per-request. It can be set anywhere from 0 seconds up to a maximum of 12 hours.

How do I turn on long polling for an existing queue?

Edit the queue and set Receive message wait time to any value from 1 to 20 seconds, or set the ReceiveMessageWaitTimeSeconds attribute directly with SetQueueAttributes. Any value greater than 0 enables long polling.

Can WaitTimeSeconds be higher than 20 seconds?

No. 20 seconds is the maximum long polling wait time SQS supports. If you need a longer effective wait, your application has to make repeated 20-second calls rather than one longer one.

Why do I get the same message twice from SQS?

Standard queues guarantee at-least-once delivery, not exactly-once. If a message isn't deleted before its visibility timeout expires — slow processing, a crashed worker, a dropped delete call — it becomes visible again and can be received a second time. The fix is making your processing idempotent, not chasing a "bug."

What is the difference between visibility timeout and message retention period?

Visibility timeout hides a received message from other consumers temporarily, for up to 12 hours, while it's presumably being processed. Message retention period is how long SQS keeps a message at all before deleting it automatically, from 60 seconds up to 14 days, regardless of whether it was ever received.

Does short polling lose messages permanently?

No. A short polling call that misses a message doesn't delete or discard it — the message stays in the queue and a subsequent poll can still return it. Short polling causes missed responses, not lost data.

What is the SQS in-flight message limit?

120,000 messages for standard queues by default. FIFO queues have a lower limit. Exceeding it means newly received messages can behave unpredictably until enough in-flight messages are deleted or expire back into visibility.

Why does my FIFO queue stop returning messages after one message?

Because all your messages share the same MessageGroupId. FIFO queues never deliver more than one in-flight message per group at a time, to guarantee order. Give independent streams of work their own group IDs instead of one shared ID for everything.

How do I check if a message is delayed vs actually missing?

Check the ApproximateNumberOfMessagesDelayed CloudWatch metric, or read the queue's DelaySeconds attribute. A non-zero delayed count means messages are waiting out a delivery delay or per-message timer, not missing.

Can IAM permissions cause ReceiveMessage to return nothing instead of an error?

Missing sqs:ReceiveMessage permission itself typically throws an explicit AccessDenied error rather than an empty response. If you're getting a clear error, look at the caller's IAM policy and the queue's access policy together — cross-account access needs both to agree.

How do I stop Lambda from processing the same SQS message twice?

Enable ReportBatchItemFailures in the event source mapping's function response types, and have your function return only the message IDs that actually failed. Without it, one failure in a batch can cause every message in that batch — including successful ones — to be retried.

What is a dead-letter queue and do I need one?

A dead-letter queue is a separate queue that receives messages after they've been received more times than a configured maxReceiveCount (default 10) without being successfully deleted. You need one anywhere a malformed or unprocessable message could otherwise loop indefinitely and consume capacity.

How many messages can ReceiveMessage return in one call?

Up to 10 per call, set with MaxNumberOfMessages. The default if you don't specify it is 1. SQS may return fewer than requested even when more are available.

Does polling an empty queue cost money?

Short polling and long polling are billed the same way. Long polling reduces the number of empty responses you generate, which can lower costs for a workload that would otherwise be making frequent short-polling calls against a mostly-idle queue.

Is there a way to see exactly how many messages are stuck as "in flight"?

Yes — the ApproximateNumberOfMessagesNotVisible CloudWatch metric, or the equivalent attribute from GetQueueAttributes. A steadily climbing value over hours, rather than a flat or fluctuating one, points to a deletion or processing bug rather than normal traffic.

Revision note. Written August 2026, covering current SQS standard and FIFO queue behavior, Lambda's SQS event source mapping, and the console features available at time of writing. This will need a look if AWS changes the default visibility timeout, the 20-second long-polling ceiling, or the in-flight message limits. If you've spent an afternoon staring at a queue that says it has messages while your code insists it doesn't, you're not missing something obvious — you're up against defaults that are genuinely easy to misread, and it does get easier from here.

Related