SQS Messages Arriving Twice in AWS? At-Least-Once Delivery Fix — and How to Prevent Duplicates (FIFO, Idempotent Consumers & Visibility Timeout)

Logeshwaran
—

If you're seeing the same Amazon SQS message land in your consumer twice, that's not a bug you need to chase down — Standard queues use at-least-once delivery, which means duplicates are an accepted, expected part of the design, not an edge case. The fix isn't a setting that promises single delivery, because no such setting exists for Standard queues; the fix is writing a consumer that doesn't care if it sees the same message more than once. Here's the part almost nobody expects: even SQS FIFO queues, marketed as "exactly-once processing," only promise the queue itself won't inject a second copy — your own code can still run twice if it crashes after doing the work but before telling SQS it's done.

⚡ Quick Answer

• Standard queues → duplicates are documented and expected. Design your consumer to be idempotent (safe to run twice) rather than trying to eliminate duplicates entirely.

• FIFO queues → the queue itself won't add duplicates within a 5-minute window, but a crash between "finish the work" and "delete the message" can still run your code twice.

• Using Lambda? → set the queue's visibility timeout to at least 6× your function's timeout — this single number causes more duplicate-processing tickets than anything else on this page.

Jump to why duplicates happen, the Lambda visibility-timeout trap, or how to build an idempotent consumer.

Jake runs a small phone repair shop. He set up an SQS queue so that when a customer books a screen replacement online, a message drops into the queue, and a background process deducts the replacement part from inventory and texts the customer a confirmation. One Tuesday, a customer called, annoyed, because she'd gotten the same confirmation text three times and the shop's inventory count showed her replacement screen used twice — for one repair.

‍♂️ Jake's Reality Check

"I only sent that message once. Why did my system act on it three times? Is SQS broken?"

No — this is exactly how Amazon designed it to work. Standard SQS queues are documented as at-least-once delivery. That's not a warning label tucked in the fine print; it's the headline behavior of the service. The same message copy can be handed to your consumer more than once, and the system is functioning correctly when that happens.

Why does SQS deliver the same message more than once?

Here's what's actually going on, in plain terms. Amazon SQS stores multiple copies of every message across several servers, for redundancy — so if one server goes down, your message isn't lost. On rare occasions, one of the servers holding a copy of your message is unavailable at the exact moment you receive or delete it. When that server comes back, it still has its copy, and it may hand that copy out again the next time a consumer asks for messages. That's the first, narrowest cause of duplicates, and it's genuinely rare.

The far more common cause has nothing to do with server unavailability and everything to do with timing. Amazon Simple Queue Service (SQS) is a fully managed message queue — think of it as a waiting line for tasks that one part of your application drops off and another part picks up later, so the two parts don't have to talk to each other directly or be online at the same time. When your consumer receives a message, SQS doesn't delete it right away. It hides the message from other consumers for a set period — the visibility timeout — and waits for your consumer to explicitly say "I'm done, delete this" by calling the DeleteMessage action. If your consumer is still working when that timeout runs out — because processing took longer than expected, the consumer crashed, or a network hiccup swallowed the delete request — the message becomes visible again and gets handed to a consumer (possibly the same one, possibly a different one) for another attempt. Your first consumer might have actually finished the work; SQS just never found out.

There's also a third source of duplicates that catches people off guard: the message producer, not the queue. If the code that sends a message to SQS times out waiting for a response and retries the send, or a Lambda function is triggered twice by an upstream event, two genuinely separate messages — with two different message IDs — can land in the queue carrying identical content. SQS has no way to know these are "the same" event; as far as the queue is concerned, they're two unrelated messages that happen to have the same body. Standard queues don't deduplicate on the way in at all.

A fourth cause, less obvious but worth naming here because it comes up in more detail further down this page: when SQS sits behind Amazon SNS in a fan-out setup, the delivery from SNS to each subscribed queue is itself at-least-once. That adds a second layer where a duplicate can be introduced before your consumer ever sees the message.

Cause How often it happens Fixable by configuration?
A storage server holding a message copy is briefly unavailable at receive/delete time Rare, documented by AWS as an occasional edge case No — inherent to at-least-once delivery
Consumer doesn't delete the message before the visibility timeout expires The most common cause in real systems Partly — tune the timeout and add a heartbeat
Producer sends the same logical event twice (retry after a timeout, duplicate trigger upstream) Common in retry-heavy or multi-service architectures Yes, with FIFO deduplication — not on Standard queues
Lambda's visibility timeout is set too short relative to the function's actual run time Very common — the single most-reported cause on AWS support forums Yes — a configuration fix
SNS-to-SQS fan-out retries a delivery to a subscribed queue Occasional, on throttling or brief connectivity issues Yes, with SNS FIFO topics for strict cases; otherwise, idempotency

✅ Why this is the one to fix first

Of the causes above, the Lambda visibility-timeout mismatch is the one you can eliminate in five minutes with a single number change, and it's responsible for the majority of "why am I getting duplicates" support tickets. Start there before touching anything else — it's the highest-leverage fix on this page.

Standard queues vs. FIFO queues: what "exactly-once" really covers

Amazon SQS comes in two queue types, and the choice between them changes how much of the duplicate problem the queue solves for you versus how much your code still has to handle.

Standard queues: at-least-once delivery, best-effort order

A Standard queue is the default queue type and the one most people reach for first. It offers at-least-once delivery — meaning a message will get to a consumer at least once, but might be delivered two or more times — and best-effort ordering, meaning messages usually, but not guaranteed, arrive in the order they were sent. Standard queues are built for near-unlimited throughput, which is exactly why they trade away strict ordering and single delivery: guaranteeing "exactly once, in exact order" at massive scale across many servers is a genuinely hard distributed-systems problem, and Standard queues are designed to prioritize scale over that guarantee.

FIFO queues: exactly-once processing, strict order — with an asterisk

FIFO (First-In-First-Out) queues add two things Standard queues don't have: strict message ordering within a message group, and what Amazon calls exactly-once processing. On a FIFO queue, if you retry a SendMessage call within a 5-minute deduplication window, SQS won't add a second copy of that message to the queue. That's a real, useful guarantee — it stops your producer's network retries from turning into duplicate queue entries.

⚠️ What "exactly-once processing" does not cover

FIFO's exactly-once guarantee is about the queue, not your application. It stops SQS from silently storing two copies of the same message. It does not make your database write and your DeleteMessage call one atomic operation. If your consumer finishes the real work — charges the card, deducts the part, sends the text — and then crashes or times out before it deletes the message, SQS still doesn't know the work happened, and the message comes back for another attempt. On a FIFO queue that second attempt runs your code again, exactly the same as it would on a Standard queue. FIFO removes queue-introduced duplicates; it does not remove the need for an idempotent consumer.

This is the gap that trips up teams who migrate to FIFO expecting the duplicate problem to vanish entirely. It shrinks — a lot, because producer-side retries stop creating extra queue entries — but the consumer-side race between "finish the work" and "tell SQS you're finished" is still there. FIFO queues also come with a real trade-off: they're capped at a lower request rate per API action than Standard queues (higher with SQS's high-throughput mode for FIFO enabled), and every message needs a MessageGroupId — messages that share a group ID are always processed strictly one at a time, in order, which is exactly what you want for something like a single customer's sequence of account changes, and exactly what you don't want if you just need raw throughput with no ordering requirement.

  Standard queue FIFO queue
Delivery guarantee At-least-once — duplicates possible Exactly-once at the queue level (5-minute dedup window)
Order Best-effort only Strict, within a message group
Consumer still needs to be idempotent? Yes, always Yes — crash-after-processing duplicates are still possible
Throughput Nearly unlimited Lower per message group, or higher with high-throughput mode enabled
Good fit for High-volume background jobs, notifications, fan-out work Order-sensitive workflows: financial ledgers, account state changes, ticketing

How the visibility timeout actually works, step by step

Ethan, Jake's mentor, walked him through it with a whiteboard sketch of the repair-shop queue. "Think of the visibility timeout like a sticky note you slap on a ticket the moment you pick it up," Ethan said. "It says 'someone's already on this' so nobody else at the counter grabs the same ticket. But if you walk away and don't finish before the note falls off, the ticket goes back in the pile — visible to whoever's next, even if you actually finished the job and just forgot to mark it done."

Here's what happens on an SQS message, in order:

  1. A message is sent to the queue. It sits there in the "available" state, waiting for a consumer to ask for it.
  2. A consumer calls ReceiveMessage. The moment SQS hands the message over, the visibility timeout clock starts. The default is 30 seconds; you can set it anywhere from 0 seconds up to a maximum of 12 hours, either for the whole queue or for one specific message.
  3. The message becomes temporarily invisible to every other consumer polling that queue. This is what stops two workers from grabbing the same ticket at the same moment — but it's a soft lock with an expiration date, not a permanent one.
  4. Your consumer processes the message. The countdown keeps running the entire time, no matter how much progress you've made. SQS has no idea whether you're 10% done or 90% done — it only knows the clock.
  5. One of two things happens next. Either your consumer finishes and calls DeleteMessage before time runs out — in which case the message is gone for good — or the timeout expires first, and the message pops back into "available," ready to be handed to a consumer again.
  6. If it comes back, a new receive gets a new receipt handle. Every time you receive the same message, SQS gives you a fresh receipt handle — the token you need to delete it or adjust its visibility. The old handle from the previous receive stops being valid, which is why you always use the handle from your most recent receive.

Notice what's absent from that sequence: nothing about the visibility timeout guarantees a message won't be delivered more than once during that window. Because of the at-least-once delivery model, there's no absolute guarantee a message won't be delivered again within that window. The timeout reduces the odds of simultaneous double-processing; it doesn't eliminate the possibility of sequential double-processing.

The practical levers you actually control here are the timeout length and whether you extend it mid-flight. If your processing genuinely takes longer than the timeout — say, generating a large report or calling a slow third-party API — you can call ChangeMessageVisibility partway through to buy more time, sometimes called a "heartbeat." That resets the clock from the moment you call it, not from the original receive time, and it's capped at 12 hours total from the first receive, no matter how many times you extend it. If a task might genuinely run longer than that, the honest answer is to break it into smaller steps rather than fighting the ceiling.

One detail worth knowing if you're building the heartbeat pattern yourself: when you call ChangeMessageVisibility on a message you've already received, the new timeout value is applied immediately for that receive, but it isn't remembered for the next time the message is received. If you never delete the message and it comes back around later, its visibility timeout reverts to the queue's original configured value, not the value you last set. Design your retry logic around that behavior rather than assuming a visibility change carries over across receives.

The single most common cause: Lambda and SQS visibility timeout mismatches

If you're triggering an AWS Lambda function from an SQS queue — which is one of the most common SQS setups in serverless applications — this is very likely where your duplicates are coming from, and it's the easiest one to fix.

Lambda's SQS integration polls the queue on your behalf and hands batches of messages to your function. The queue's visibility timeout should be at least six times your function's configured timeout, and your function's timeout must be less than or equal to the queue's visibility timeout — Lambda checks this when you create or update the trigger and will reject a mismatched configuration outright. The extra headroom exists so Lambda has room to retry a batch if it gets throttled partway through, without the message becoming visible again mid-attempt.

When that ratio is off — say, a 5-minute Lambda function sitting behind a queue that still has the 30-second default visibility timeout — here's exactly what happens: Lambda receives the message, starts running your function, and while it's still working, the 30-second timeout expires. SQS assumes the first attempt failed and hands the message to another Lambda invocation. Now you have two Lambda executions running against the same message, and if your function isn't written to survive that, you get duplicate charges, duplicate emails, or duplicate inventory deductions — exactly what happened to Jake's shop.

  1. Check your function's timeout. Go to your Lambda function's configuration and note the exact value in seconds.
  2. Multiply by six. That's your visibility timeout floor. For a 30-second function, that's 180 seconds; for a 5-minute function, that's 1,800 seconds.
  3. Add your batching window, if you use one. If your event source mapping has a maximum batching window configured, add those seconds on top, since Lambda waits that long before invoking on a partial batch.
  4. Set the queue's visibility timeout to that total, either in the SQS console under the queue's configuration, or with aws sqs set-queue-attributes --attributes VisibilityTimeout=<seconds> from the CLI. Remember the ceiling: 12 hours (43,200 seconds) is the maximum.
  5. Turn on partial batch responses (ReportBatchItemFailures) if your function processes multiple messages per invocation. Without it, one failed message in a batch sends the entire batch back to the queue — including messages that already succeeded — which manufactures duplicates out of otherwise-successful work. With it enabled, your function reports exactly which message IDs failed, and only those go back for retry.

‍♂️ Jake's Reality Check

"So if I just crank the visibility timeout way up — like a full hour, just to be safe — that solves it, right?"

Not quite, and Ethan pushed back on this one hard. "You're solving the wrong problem," he told Jake. "A huge visibility timeout doesn't make your function faster or your delete call more reliable — it just means a genuinely stuck or failed message sits invisible for an hour before anyone notices, which slows down your retries and your dead-letter queue. Set the timeout to match your real processing time plus buffer, not to some arbitrary 'safe-sounding' number."

SNS-to-SQS fan-out: a second, separate source of duplicates

Jake's shop also runs a small marketing flow: every completed repair publishes one event to an SNS topic, which fans out to two SQS queues — one that triggers a follow-up email, and one that logs the repair for a monthly report. When Jake noticed the follow-up email queue occasionally firing twice for the same repair, his first assumption was that his own queue's visibility timeout was still misconfigured. It wasn't. The duplicate was introduced one layer up, in SNS itself.

Amazon SNS, like SQS, is documented as at-least-once delivery, not exactly-once. When you subscribe an SQS queue to an SNS topic, SNS delivers each published message to the queue at least once; if SNS doesn't get a clean acknowledgment from SQS on a delivery attempt — a throttling response, a brief network blip — SNS retries that specific delivery. That retry can land the same logical message in the subscribed queue more than once, as a genuinely separate SQS message with its own message ID. That makes it a producer-side duplicate rather than a visibility-timeout redelivery — which means the receiver-side idempotency check covered in the next section is exactly what catches it.

There's a meaningfully different setup if ordering and deduplication actually matter across your fan-out: SNS FIFO topics. For workloads that need strict ordering or true deduplication, subscribing SQS FIFO queues to an SNS FIFO topic extends the same deduplication-ID mechanism from SQS FIFO up into the topic itself — a duplicate publish to the topic, within the same 5-minute window, is deduplicated before it ever reaches any subscribed queue. The trade-off mirrors SQS FIFO's own trade-off: SNS FIFO topics can deliver only to SQS queues, not directly to email, SMS, or other customer-managed endpoints, so if your fan-out needs to reach those destinations too, you're choosing between best-effort, at-least-once delivery on the SQS-subscriber side, or splitting your fan-out into a FIFO branch and a Standard branch.

✅ Why this is the one to use, for most fan-outs

For a fan-out where every downstream queue just needs "this event happened, eventually, idempotently" — Jake's follow-up email and reporting queues — a Standard SNS topic feeding Standard SQS queues is the right, simpler default. Reach for SNS FIFO only when a specific downstream queue genuinely cannot tolerate an out-of-order or duplicate delivery. It adds real constraints for a guarantee most fan-out consumers don't actually need once they're idempotent anyway.

Building a consumer that doesn't care about duplicates

Here's the mindset shift that makes this whole problem stop being scary: stop trying to guarantee a message arrives exactly once, and start guaranteeing that processing the same message twice has no bad side effect. That property is called idempotency — a fancy word for "doing it again produces the same result as doing it once," the same way pressing an elevator button that's already lit doesn't call two elevators. Design your applications to be idempotent, because they should not be affected adversely when processing the same message more than once. That's not a nice-to-have footnote; for Standard queues, it's the whole answer.

The classic pattern — and the one Ethan walked Jake through — uses a small lookup table to remember which messages have already been handled:

  1. Give every logical event a unique key. This can be the SQS-assigned MessageId, or better, a business-meaningful ID your own system generates when the event is created (an order ID, a ticket number) — that way, even if your producer or an SNS retry accidentally sends the same event twice as two separate messages with two different MessageIds, you still catch it.
  2. Before doing the real work, check whether that key already exists in a fast lookup store — a DynamoDB table is the common choice on AWS, since a single conditional write can double as both the "has this been seen" check and the "mark it as seen" step.
  3. If the key is new, do the work and record the key as part of the same operation, or immediately after, so a crash between "do the work" and "record the key" is the only remaining gap.
  4. If the key already exists, skip the work and delete the message anyway. This is the step people forget. A duplicate isn't an error condition; it's an expected outcome. Log it if you want visibility into how often it happens, but don't treat it as a failure.
  5. Make the "real work" itself idempotent wherever you can, in addition to the dedup check. A database write like "set the balance to $50" is naturally idempotent — running it twice leaves the same result. A write like "subtract $10 from the balance" is not — running it twice subtracts $20. Prefer the first pattern whenever your data model allows it; it gives you a second layer of safety even if the dedup check somehow gets bypassed.

This is exactly the gap in Jake's shop system. The text-message-and-inventory-deduction code had no concept of "have I already handled this booking." Every delivery of the same message — whether it was the queue's doing, Lambda's, or the SNS fan-out above — ran the full sequence again: deduct a part, send a text. Once Ethan added an idempotency table keyed on the booking ID, a duplicate delivery still arrived exactly as often, but it stopped mattering: the second and third attempts hit the "already handled" check, skipped straight to deleting the message, and Jake's inventory count and the customer's inbox both went back to normal.

On the storage side of that dedup table, the detail that actually makes the pattern race-safe is a conditional write. A plain "check if the key exists, then write it" done as two separate steps still has a gap: two overlapping consumer attempts on the same message could both check, both find nothing, and both proceed to do the real work before either one writes the key. A conditional write — a DynamoDB PutItem call with a condition expression that only succeeds if the key doesn't already exist — collapses the check and the write into one atomic operation. Whichever attempt gets there first wins the write; the second attempt's conditional write fails, and that failure is your signal to treat that attempt as a duplicate and skip straight to deleting the message. This single change is what turns "probably idempotent" into "actually idempotent under concurrent processing" — a distinction that matters more than it sounds like it should the first time two consumers really do race each other on the same redelivered message.

How to tell duplicates are actually happening (not just suspected)

Before you go build an idempotency table, it's worth confirming what you're actually seeing, because "duplicate processing" and "duplicate messages in the queue" are two different symptoms with different causes.

Every SQS message carries a system attribute called ApproximateReceiveCount, which tells you how many times that message has been received across all consumers — not deleted, just received — since it entered the queue. If you're logging this value alongside your message IDs, a spike to 2 or higher on messages that should have been processed cleanly on the first attempt is a strong signal you're hitting the visibility-timeout race described above, rather than a producer sending genuine duplicates. It's called "approximate" for a reason — treat it as a strong signal, not an exact audit trail, since the count can be an estimate under some conditions.

It's also worth separating message ID from receipt handle in your own head, because confusing the two causes real bugs. The message ID is assigned once, when the message is first sent, and stays the same for the life of that message. The receipt handle is different every single time you receive that message — it's tied to that specific receive attempt, not to the message itself. If your logs show the same message ID appearing with different receipt handles, that's the queue redelivering one message. If you see two entirely different message IDs carrying identical-looking content, that's your producer — or, per the section above, SNS — creating separate messages, a different problem with a different fix.

 The distinction that actually matters

  • Same message ID, seen more than once: normal at-least-once behavior — a receive/delete timing issue, usually visibility timeout related.
  • Different message IDs, same content: a producer-side (or SNS-side) duplicate — a separate message was created upstream. FIFO deduplication or an idempotency key at send time is the right fix here, not a receiver-side check alone.
  • What this means for your fix: if you only build a receiver-side idempotency check, you'll catch both cases — which is exactly why that pattern is the recommended default even once you know the specific cause.

Watching for duplicate trouble with CloudWatch, before a customer tells you

Jake found out about his triple-texted customer because she called the shop. Ethan's next move was to make sure the shop's system found out first. Amazon SQS publishes a set of metrics to CloudWatch automatically, at no extra setup beyond turning on an alarm, and several of them are specifically useful for catching duplicate-processing trouble before it becomes a support call.

Metric What a problematic reading looks like
NumberOfMessagesReceived vs. NumberOfMessagesSent Received climbing meaningfully above sent over the same window — that gap is redelivery
ApproximateAgeOfOldestMessage Steadily climbing — the oldest message isn't clearing, often because it's stuck in a retry loop
ApproximateNumberOfMessagesNotVisible Unusually high and not draining — a lot of in-flight messages are stuck mid-processing
NumberOfEmptyReceives Very high alongside a high receive count — consumers are polling aggressively, worth checking against your visibility timeout settings

The single most useful comparison for this specific problem is NumberOfMessagesSent against NumberOfMessagesReceived over the same window. In a healthy queue with no redelivery happening, received should track sent fairly closely. When received climbs meaningfully above sent, that gap is redelivery — the same messages being handed out more than once — and it's a much earlier warning sign than waiting for a customer complaint or a downstream data anomaly to surface the problem. Pair that comparison with an alarm on ApproximateAgeOfOldestMessage, since a climbing age on the oldest message in the queue often means a duplicate-processing loop is chewing through retries instead of messages clearing normally.

None of this requires custom instrumentation inside your consumer code to get started — it's queue-level visibility you get simply by wiring a CloudWatch alarm to metrics SQS is already publishing. Application-level logging of ApproximateReceiveCount, covered above, is the complementary layer once you want to trace a specific duplicate back to a specific message rather than just knowing duplicates are happening somewhere in the queue.

Dead-letter queues: stopping a duplicate loop from running forever

There's a nastier version of the duplicate problem worth naming directly: a message that fails every single time it's processed. Without a safety net, that message just keeps bouncing — delivered, failed, visibility timeout expires, delivered again — forever, burning compute and cluttering your logs with what looks like an endless stream of duplicates.

A dead-letter queue (DLQ) is a separate queue you designate to catch messages that have failed too many times. You attach it to your source queue with a redrive policy that sets maxReceiveCount — the number of times a message can be received before SQS gives up and routes it to the DLQ instead of back to the main queue. The default value, if you don't set one explicitly, is 10. Set it high enough to allow for genuine retries — a value as low as 1 means a single transient failure banishes a message to the DLQ immediately, which usually isn't what you want, since it defeats the purpose of retrying at all.

A DLQ doesn't prevent duplicate processing by itself — that's a common misconception. What it does is put a ceiling on how many times a broken message can be reprocessed before your system stops trying and flags it for a human to look at. Pair it with a CloudWatch alarm on the DLQ's message count, and a message that's failing repeatedly — whether from a genuine bug or from something like a malformed payload — gets surfaced quickly instead of quietly consuming retries for days. One structural note worth knowing before you reach for a DLQ on a FIFO queue: since FIFO processes each group strictly in order, moving a mid-sequence message to the DLQ can affect the order of everything behind it in that group. For workloads where sequence genuinely matters — the exact example AWS gives is a list of edits where reordering changes the meaning of what follows — think through whether a DLQ is the right tool before wiring one up.

Keeping duplicate-prone messages secure while you debug them

Chasing down a duplicate usually means logging message content somewhere so you can compare copies — and that's the moment worth pausing on if your messages carry anything sensitive, like a customer's name, phone number, or payment reference, the way Jake's booking messages do.

Amazon SQS encrypts messages at rest by default on newly created queues using SQS-managed encryption keys (SSE-SQS), and it protects data in transit the standard way any HTTPS API call does — this happens automatically, with no code changes required, and at no additional charge over the queue itself. If you need more control over the encryption key — a customer-managed key with its own rotation schedule and access policy, for audit or compliance reasons — SSE-KMS is the alternative, using a key you manage in AWS Key Management Service instead of the SQS-managed default.

The part that's easy to get wrong isn't the queue's own encryption — it's what you do with a message once you've pulled it out to debug a duplicate. An idempotency table, a debug log, a CloudWatch Logs statement: none of those inherit the queue's encryption automatically just because the queue itself was encrypted. If the message body contains anything personally identifying, log the message ID and the dedup key instead of the full body wherever that's enough to diagnose the issue. If you must log the body itself while chasing a specific duplicate, treat that log the same way you'd treat the original message — encrypted at rest, access-restricted, and short-lived rather than kept indefinitely.

The mistakes that cause duplicates, ranked by how often we see them

"Half the internet is telling me to install some third-party deduplication library," Jake said at one point, scrolling through search results. "Do I actually need that?" Ethan's answer: almost never, for a system Jake's size. The fixes that matter are configuration and a small dedup table, not a new dependency.

Working from cheapest to most involved:

  1. Default visibility timeout left at 30 seconds with slow processing. The single most common root cause. Fix: match it to your real processing time, per the Lambda 6x rule above even if you're not using Lambda — the same logic applies to any consumer.
  2. Deleting the message before processing finishes, "just to be safe." This backfires in the opposite direction — if your process then crashes, you've lost the message entirely with no chance of retry, and you won't even see it as a duplicate; you'll see it as data that silently never got handled. Always delete last, after the work is confirmed done.
  3. No idempotency check on a Standard queue. If you've read this far, you know why this one matters — it's the gap that turns an expected, harmless redelivery into a duplicate charge or a duplicate email.
  4. Processing a whole Lambda batch as all-or-nothing. Without ReportBatchItemFailures, one bad message in a batch of ten sends all ten back for reprocessing, manufacturing nine duplicates out of nine successes.
  5. Assuming FIFO solves everything and skipping idempotency entirely. Covered above — FIFO shrinks the problem significantly but doesn't close the crash-after-processing gap, and doesn't cover duplicates introduced upstream by an SNS fan-out either.
  6. maxReceiveCount set to 1 or 2. This doesn't cause duplicates directly, but it means a message that would have succeeded on a second honest attempt gets exiled to the DLQ instead — and when someone manually redrives DLQ messages back to the source queue without checking whether they were actually processed the first time, that's a genuine duplicate-processing risk introduced by the fix itself.

⚠️ What this actually breaks

Deleting a message the instant it's received, before your processing logic runs, is the fastest way to turn "possible duplicate" into "guaranteed data loss." If the process dies mid-task after that delete, there is no second chance — SQS has no record the message ever existed. This is worse than a duplicate, and it's silent, which makes it worse still.

When a duplicate is harmless — and when it genuinely isn't

Not every duplicate deserves the same amount of engineering effort. It's worth being honest about which of your queues actually carry risk, because building a full idempotency layer for a queue where duplicates genuinely don't matter is wasted work.

Low-stakes, generally safe to leave alone: log ingestion where a repeated line just gets deduplicated downstream anyway; cache invalidation messages, where re-invalidating an already-fresh cache entry is a no-op; a "recalculate this user's recommendations" job, where running it twice just means slightly wasted compute, not a wrong outcome.

High-stakes, where you genuinely need idempotency: anything that touches money — charges, refunds, ledger entries; anything that sends a customer-facing notification, since a duplicate text or email is the kind of thing customers actually notice and complain about, as Jake found out; anything that decrements a finite resource, like inventory or a limited-quantity coupon; anything that triggers an external side effect you can't undo, like kicking off a shipment or provisioning a piece of infrastructure.

Ethan's rule of thumb for Jake: "If the cost of doing it twice is 'we wasted a few milliseconds of CPU,' don't sweat it. If the cost of doing it twice is 'a customer gets angry or we lose money,' that's the queue that gets the dedup table, full stop — no exceptions, no 'we'll add it later.'"

Frequently asked questions

Is it normal for SQS to deliver the same message twice?

Yes. Standard queues are documented by AWS as at-least-once delivery, which explicitly allows for a message to be delivered more than once. It's not a malfunction — it's the trade-off Standard queues make in exchange for massive scalability.

Does the visibility timeout guarantee a message won't be delivered twice?

No. It reduces the chance by hiding a received message from other consumers for a set period, but AWS is explicit that because of the at-least-once model, there's no absolute guarantee a message won't be delivered again within that window, and if the timeout expires before you delete the message, redelivery is expected behavior.

What's the actual difference between at-least-once and exactly-once?

At-least-once means a message is guaranteed to arrive, but might arrive more than once — you have to handle duplicates yourself. Exactly-once (as FIFO queues implement it) means the queue itself won't introduce a duplicate copy, but as covered above, it still doesn't guarantee your consumer's business logic only runs once if a crash happens between finishing the work and deleting the message.

Do FIFO queues ever send duplicates?

The queue itself is designed not to introduce duplicate messages, and within a 5-minute deduplication window, retried sends of an identical message are collapsed into one. But your producer can still create genuinely separate messages outside that window, and your consumer can still process one message twice if it crashes after doing the work but before calling DeleteMessage.

What is a message deduplication ID?

It's a token used only on FIFO queues to tell SQS which messages are duplicates of each other. If two SendMessage calls within the 5-minute deduplication window share the same deduplication ID, SQS accepts the first and silently acknowledges — but doesn't deliver — the second.

What is content-based deduplication, and should I turn it on?

It's a FIFO queue option where SQS generates the deduplication ID automatically by hashing the message body, instead of you having to supply one explicitly. It's convenient when your message content itself is a reliable indicator of uniqueness. If two logically different messages could ever have identical bodies, provide your own explicit deduplication ID instead, since content-based hashing would incorrectly treat them as duplicates.

How long does the FIFO deduplication window last?

Five minutes. Amazon SQS continues tracking a deduplication ID for that window, and a duplicate SendMessage call with a matching ID outside that window is treated as a brand-new message rather than a duplicate.

Why do I only see duplicates when I use Lambda with SQS?

The most common reason is a visibility-timeout mismatch: your Lambda function's configured timeout is longer than the queue's visibility timeout, so SQS releases the message for redelivery while your function is still mid-execution. Set the queue's visibility timeout to at least six times the function's timeout to avoid exactly this.

What visibility timeout should I set for a Lambda-triggered SQS queue?

At minimum, six times your Lambda function's configured timeout, plus the maximum batching window if your event source mapping uses one. Lambda enforces the reverse relationship at configuration time and will reject a setup where the function's timeout exceeds the queue's visibility timeout.

What does ApproximateReceiveCount actually tell me?

It's a message attribute reporting how many times that message has been received since it entered the queue, across all consumers — not how many times it's been deleted or successfully processed. It's called "approximate" because the count is an estimate, so treat repeated high values as a strong signal rather than an exact record.

Can I use ApproximateReceiveCount to detect duplicates in production?

You can use it as a diagnostic signal — logging it alongside each message ID helps you spot whether redeliveries are happening and roughly how often — but because it's approximate, don't build your actual deduplication logic on top of it. Use a dedicated idempotency key check for the deduplication decision itself.

What happens if my consumer never deletes a message at all?

It keeps cycling: delivered, visibility timeout expires, delivered again, indefinitely — until it hits your queue's maxReceiveCount, at which point it's routed to the dead-letter queue if one is configured, or it simply keeps circulating in the main queue until its retention period expires and it's discarded.

Will setting up a dead-letter queue stop duplicate processing?

Not directly. A DLQ caps how many times a failing message can be retried before it's set aside for a human to investigate, which stops an endless retry loop from running forever — but it doesn't prevent the redeliveries that happen before that cap is reached. You still need an idempotent consumer for those.

What is ReportBatchItemFailures, and does it reduce duplicates?

It's a Lambda event source mapping option that lets your function tell Lambda exactly which messages in a batch failed, so only those specific messages return to the queue for retry. Without it, any single failure in a batch sends the entire batch back — including already-successful messages — which manufactures duplicate processing for work that already completed correctly.

Should I switch from Standard to FIFO purely to get rid of duplicates?

Only if ordering also matters to your workload, since FIFO's lower throughput per message group and the message-group-ID requirement are real trade-offs. If ordering doesn't matter to you, an idempotency check on a Standard queue solves the duplicate problem just as effectively, without giving up Standard's throughput.

How do I make my consumer idempotent without setting up a database?

For simple cases, design the operation itself to be naturally idempotent — "set the value to X" instead of "add X to the value," or "mark order 123 as shipped" instead of "increment shipped count" — so repeating it produces the same end state either way. That covers a lot of ground on its own; a dedicated dedup table is the more robust fallback for operations that can't be reshaped that way.

Revision note. Written September 2026. AWS occasionally adjusts default quotas and console workflows, so if a specific number here — like the default visibility timeout or the in-flight message limit — looks off from what you're seeing in your own account, the live AWS documentation is the source of truth. If you're the one staring at duplicate charges or duplicate emails right now trying to figure out what went wrong, take a breath — this is one of the most common growing pains in queue-based systems, and it's fixable with configuration, not a rewrite.

For readers preparing for an AWS certification

We have been covering AWS certifications alongside our fixes, so here is a short extra for readers studying for one. If you only came for the fix, you already have it; feel free to skip this part.

Exams that test this topic: Developer – Associate (DVA-C03 from December 1, 2026) • Solutions Architect – Associate (SAA-C03).

Here are the exam-style questions this topic tends to raise, with the reasoning you need to answer them:

1. Why can a standard queue deliver the same message twice?

Standard queues guarantee at-least-once delivery, so occasional duplicates are by design.

2. How do you make processing safe when duplicates arrive?

Make the consumer idempotent, for example by recording processed message IDs in DynamoDB with a conditional write.

3. How does a FIFO queue remove duplicates?

With a deduplication ID (or content-based deduplication) within a 5-minute deduplication interval.

4. A message is processed twice because processing outlasts the visibility timeout. What fixes it?

Raise the visibility timeout above the processing time, or extend it with ChangeMessageVisibility during long work.

5. Which attribute counts how often a message was received?

ApproximateReceiveCount.

6. Does Lambda with SQS guarantee exactly-once processing?

No. Event source mappings deliver at least once, so functions must be idempotent.

Exam codes and dates change often. Our AWS certification changes page keeps track of what is current.

Related