FIFO Queues
"FIFO preserves ordering" is not a useful sentence. It is true, it is what every tutorial says, and it tells you nothing you can design with.
The sentence you need is:
📌 Ordering is guaranteed within a message group. Across groups, nothing is guaranteed.
That qualifier — within a message group — determines your throughput, your parallelism, your failure blast radius and your concurrency ceiling. Choosing the group key is the single most consequential decision in a FIFO design, and it is made in one line of producer code that looks trivial.
This module also corrects the most consequential misreading in the SQS documentation, which is AWS's use of the phrase "exactly-once processing".
1. What you will learn
- State exactly what FIFO guarantees and what it does not
- Explain
MessageGroupIdas the unit of both ordering and parallelism - Explain deduplication: explicit ids, content-based hashing, and the 5-minute window
- Calculate FIFO throughput, with and without batching and high-throughput mode
- Explain head-of-line blocking and its blast radius
- Choose between standard and FIFO with reasons rather than reflexes
2. Why FIFO exists
Some workloads genuinely cannot be reordered:
- A bank ledger — a withdrawal must not be applied before the deposit that funds it
- A state machine —
CREATED → PAID → SHIPPEDis not the same asSHIPPED → CREATED → PAID - A change feed — apply
set name = "Bob"thenset name = "Alice", not the reverse - An edit decision list — reordering edits changes what subsequent edits mean
Module 06 §5 showed how to make most workloads order-independent. These resist it, because the ordering is intrinsic to the business meaning rather than incidental.
But here is the tension. Global ordering means one message at a time, in sequence, forever. A queue with a strict total order can have exactly one consumer making progress, because the second message cannot start until the first finishes. That is not a queue, it is a pipe.
FIFO resolves this by making ordering scoped. You declare which messages must be ordered relative to each other; everything else runs in parallel. The scope is the message group.
Why does FIFO need a
MessageGroupIdat all? Because without it, FIFO would have to assume everything is ordered relative to everything else, which means serial processing and no scaling. The group id is how you tell SQS which ordering constraints are real — and by implication, where it is free to parallelise.
3. What FIFO actually guarantees
Three guarantees, each with fine print.
Guarantee 1 — ordering within a group
Messages with the same MessageGroupId are delivered in the order they were sent, and the next
one is not delivered until the current one is deleted or its visibility expires.
That second clause is doing enormous work. It is what makes ordering real — and it is also head-of-line blocking (§7), which people discover later and unhappily.
flowchart LR
subgraph SENT["Sent"]
direction TB
S1["A1 · group A"]
S2["B1 · group B"]
S3["A2 · group A"]
S4["A3 · group A"]
S5["B2 · group B"]
S1 --> S2 --> S3 --> S4 --> S5
end
SENT --> Q[("FIFO queue")]
Q --> GA
Q --> GB
subgraph GA["Group A — strict order ✅"]
direction TB
A1["A1"] --> A2["A2"] --> A3["A3"]
end
subgraph GB["Group B — strict order ✅"]
direction TB
B1["B1"] --> B2["B2"]
endReading the diagram. Within A: A1, A2, A3, always, no exceptions. Within B: B1, B2, always.
Between A and B: no guarantee whatsoever. B2 may be processed before A1. The groups are
independent universes, and nothing correlates them.
⚠️ Common mistake. Expecting the interleaving to be preserved. The queue does not promise that
B1comes afterA1merely because it was sent later. Only same-group order is promised.
Guarantee 2 — no duplicate sends within 5 minutes
If you retry a SendMessage within the deduplication interval, SQS accepts the call and discards
the duplicate rather than enqueueing it twice (§5).
Guarantee 3 — the one that is misread
AWS's documentation heads this section "Exactly-once processing" and says: "Unlike standard queues, FIFO queues don't introduce duplicate messages."
Read it precisely. SQS does not introduce duplicates into the queue. That is a statement about the send side.
It is not a statement about your consumer. A consumer that receives a message, does the work,
and crashes before calling DeleteMessage will get that message again — on a FIFO queue, exactly as
on a standard one. The visibility timeout expires, the message is redelivered, and the work happens
twice.
flowchart TB
subgraph SEND["① SEND side — FIFO deduplication protects you ✅"]
direction LR
P["Producer"] -->|"SendMessage(dedupId=X)"| Q1[("FIFO queue")]
P -->|"retry, same dedupId,<br/>within 5 min"| Q1
Q1 --> ONE["Exactly ONE message<br/>enqueued ✅"]
end
subgraph RECV["② RECEIVE side — FIFO does nothing ❌"]
direction LR
Q2[("FIFO queue")] -->|"receive"| C1["Consumer"]
C1 --> W1["work done ✅"]
W1 --> X["💥 crash before<br/>DeleteMessage"]
X -->|"visibility expires"| Q2
Q2 -->|"receive again"| C2["Consumer"]
C2 --> W2["work done AGAIN ⚠️"]
end
SEND ~~~ RECVReading the diagram. Two different halves of the system. FIFO's deduplication guards ① only. ② is guarded by your idempotency, and by nothing else (Module 09).
📌 "Exactly-once processing" in AWS's wording means "exactly-once enqueueing". Your consumer can still run twice. This is the single most consequential misreading in the SQS documentation, and it has shipped double-charges to production.
🎯 Interview point. "Does FIFO give you exactly-once processing?" is a trap question. The correct answer names the two halves: deduplication protects sends within a 5-minute window; consumer redelivery after a crash is unaffected, so idempotency is still required.
4. MessageGroupId — the whole design
MessageGroupId is required on every FIFO send. Omit it and the call fails. Up to 128
characters (aws-facts.md §5).
sqs.sendMessage(r -> r
.queueUrl(fifoQueueUrl)
.messageBody(body)
.messageGroupId(accountId) // ← the entire design lives here
.messageDeduplicationId(transactionId));It simultaneously determines three things, which is why it deserves real thought:
| It sets | Meaning |
|---|---|
| Ordering scope | Which messages are ordered relative to each other |
| Parallelism ceiling | You cannot process more concurrently than you have active groups |
| Failure blast radius | One stuck message blocks its entire group, and nothing else |
Parallelism across groups
flowchart TB
Q[("FIFO queue")]
Q --> G1["Group: acct-1001<br/>m1 → m2 → m3"]
Q --> G2["Group: acct-1002<br/>m4 → m5"]
Q --> G3["Group: acct-1003<br/>m6 → m7 → m8"]
Q --> G4["Group: acct-1004<br/>m9"]
G1 --> C1["Consumer A<br/>serial within group"]
G2 --> C2["Consumer B"]
G3 --> C3["Consumer C"]
G4 --> C4["Consumer D"]Reading the diagram. Four groups, four consumers working simultaneously. Inside each group, strictly one at a time. This is the whole trick: parallelism comes from the number of groups, and ordering comes from the serialisation inside each one.
Choosing the key
This is the decision. Get it wrong in either direction and you have a bad system.
| Group key | Groups | Parallelism | Verdict |
|---|---|---|---|
"orders" (one constant) | 1 | 1 | ❌ Fully serial. 300 TPS ceiling. One bad message halts everything |
customerId | ~200,000 | High | ✅ Usually correct — per-customer ordering, broad parallelism |
region | 5 | 5 | ⚠️ Ordering is probably not per-region; parallelism is poor |
UUID.randomUUID() | = message count | Maximal | ❌ No two messages are ever ordered. You bought FIFO's cost for standard's behaviour |
📌 The right key is the entity whose ordering actually matters —
accountId,customerId,orderId,aggregateId. Ask: "which two messages would break things if inverted?" Whatever they have in common is your group key.
⚠️ Both failure modes are common. One constant group is the classic beginner error. A random UUID per message is the classic "make the throughput warning go away" error — it works, the alarm stops, and the ordering guarantee you were paying for silently no longer exists.
⚠️
MessageGroupIdon a standard queue means something completely different — fairness, not ordering (Module 06 §8). Same parameter name, different semantics, depending on queue type.
5. Deduplication
FIFO's send-side protection. It needs a deduplication id, obtained one of two ways.
Option A — explicit MessageDeduplicationId
.messageDeduplicationId(transactionId) // you supply the identityOption B — content-based deduplication
Enable it on the queue and SQS computes a SHA-256 of the message body.
sqs.createQueue(r -> r
.queueName("sqs-course-payments.fifo")
.attributes(Map.of(
QueueAttributeName.FIFO_QUEUE, "true",
QueueAttributeName.CONTENT_BASED_DEDUPLICATION, "true")));⚠️ Content-based deduplication hashes the body only — not the message attributes (
aws-facts.md§5).Two messages with identical bodies and different attributes are treated as duplicates. The second is silently discarded. There is no error, no metric, and no log line — the send returns success.
This has caused real production incidents. If any part of what distinguishes your messages lives in an attribute, you must use an explicit
MessageDeduplicationId.
The 5-minute window
sequenceDiagram
autonumber
participant P as Producer
participant S as SQS FIFO
P->>S: SendMessage(dedupId="tx-771")
S-->>P: accepted — enqueued ✅
Note over S: dedup window for "tx-771"<br/>open for 5 minutes
P->>S: SendMessage(dedupId="tx-771")<br/>[t + 30s — a retry]
S-->>P: accepted — DISCARDED 🗑️
Note over S: Success response.<br/>Nothing was enqueued.
Note over S: t + 5 min — window closes
P->>S: SendMessage(dedupId="tx-771")<br/>[t + 6 min]
S-->>P: accepted — enqueued AGAIN ⚠️
Note over S: Two messages now in the queue.Reading the diagram. Three details that matter:
- The window is exactly 5 minutes and is not configurable.
- A deduplicated send returns success. Your producer cannot tell the difference between "enqueued" and "discarded as duplicate". This is deliberate — it is what makes a producer retry safe — but it means you cannot use the response to detect duplicates.
- The window is wall-clock from the first send, not from the last. At 6 minutes it is a fresh message.
Why 5 minutes, and why fixed? It is sized for the case it exists to solve: a producer that did not receive a response and retried. Network-level retries happen in seconds. Five minutes is generous headroom for that, while bounding how much deduplication state SQS must retain per queue. It is not intended as general-purpose duplicate suppression, and using it as such fails at minute six.
⚠️ Deduplication is not idempotency. It covers a 5-minute window on the send side. A message resent an hour later, or redelivered to a consumer after a crash, is entirely unprotected.
DeduplicationScope
By default the dedup window applies across the whole queue. Set DeduplicationScope=messageGroup
and it applies per group instead — which is also a prerequisite for high-throughput mode (§6).
6. Throughput — the arithmetic
The part that decides whether FIFO is viable for your workload at all.
Default mode
| Limit | |
|---|---|
| Without batching | 300 TPS per API action, per partition |
| With batching (10 per call) | 3,000 messages/sec per API action |
Note "per API action": SendMessage, ReceiveMessage and DeleteMessage each get their own 300
TPS. And note per partition — this is the clause that matters.
Partitions
SQS derives an internal partition from the MessageGroupId. Throughput scales with partitions, and
partitions come from having many distinct groups.
📌 Few groups → few partitions → low throughput. The throughput limit is not really a queue property; it is a consequence of your group key design.
High-throughput mode
Enable it and the limits rise dramatically — but they are region-dependent
(aws-facts.md §4):
| Region group | Non-batched TPS | Batched (×10) msg/sec |
|---|---|---|
| us-east-1, us-west-2, eu-west-1 | 70,000 | 700,000 |
| us-east-2, eu-central-1 | 19,000 | 190,000 |
| ap-south-1, ap-southeast-1/2, ap-northeast-1, eu-south-2 | 9,000 | 90,000 |
| eu-west-2, sa-east-1 | 4,500 | 45,000 |
| All other Regions | 2,400 | 24,000 |
⚠️ A FIFO design that works in us-east-1 may not work in eu-west-2 — a 15× difference. For a multi-Region service, size against your smallest Region, not your largest.
sqs.createQueue(r -> r
.queueName("sqs-course-ledger.fifo")
.attributes(Map.of(
QueueAttributeName.FIFO_QUEUE, "true",
QueueAttributeName.CONTENT_BASED_DEDUPLICATION, "true",
// both required for high-throughput mode:
QueueAttributeName.DEDUPLICATION_SCOPE, "messageGroup",
QueueAttributeName.FIFO_THROUGHPUT_LIMIT, "perMessageGroupId")));Worked example
A payments ledger. 8,000 messages/sec sustained, in eu-west-1, per-account ordering required.
Requirement : 8,000 msg/s
Actions per message : send + receive + delete = 3 API actions
Batching : 10 per call
Calls per action : 8,000 ÷ 10 = 800 calls/sec per action
Default FIFO mode : 300 TPS per action per partition
→ 800 > 300, insufficient unless spread over
at least 3 partitions, and partition assignment
is not under your direct control
→ treat default mode as ~3,000 msg/s and reject it ❌
High-throughput mode : eu-west-1 → 70,000 TPS non-batched
800 calls/sec is 1.1% of the limit ✅
Group count : ~200,000 active accounts → ample partitions ✅
Consumer concurrency : capped by ACTIVE groups, not total groups.
At 8,000 msg/s with 200 ms processing:
Little's Law → 1,600 concurrent slots needed
→ need ≥1,600 accounts active at once ⚠️ verify thisThat last line is the one people miss. Total group count is irrelevant; concurrently active group count is the ceiling. 200,000 accounts of which 40 are transacting right now gives you a parallelism of 40 — and no amount of autoscaling changes that.
🎯 Interview point. "How does the choice of
MessageGroupIdaffect throughput?" — it determines partition count, which sets the TPS ceiling, and it determines how many groups can be in flight at once, which sets consumer concurrency. Two separate ceilings from one decision.
7. Head-of-line blocking
The cost of guarantee 1, and where FIFO hurts in production.
sequenceDiagram
autonumber
participant Q as FIFO queue<br/>group "acct-1001"
participant C as Consumer
Note over Q: group holds: m1, m2, m3, m4
Q->>C: m1
C->>C: process — fails ❌
Note over Q: m2, m3, m4 BLOCKED.<br/>Not delivered to anyone.
Note over Q: visibility expires
Q->>C: m1 again (receiveCount=2)
C->>C: fails again ❌
Note over Q: still blocked
Note over Q,C: … repeats until maxReceiveCount …
Q->>Q: m1 → DLQ
Note over Q: group unblocks.<br/>m2, m3, m4 now flow —<br/>with a permanent gap where m1 was.
Q->>C: m2Reading the diagram. While m1 is stuck, its entire group is stuck. Messages m2 through m4
are not slow — they are undeliverable, to any consumer, however many you run.
How long? Roughly maxReceiveCount × visibilityTimeout
(Module 05 §9). With maxReceiveCount = 10 and a 5-minute timeout,
one bad message blocks its group for fifty minutes.
The blast radius depends entirely on your group key
| Group key | One poison message blocks |
|---|---|
"orders" | Everything. Total outage |
customerId | One customer. Others unaffected |
region | 20% of traffic |
📌 This is the strongest argument for a fine-grained group key, and it is a reliability argument rather than a throughput one. With
customerId, a poison message is one unhappy customer. With a constant, it is an outage.
⚠️ The incident presents confusingly. Overall queue depth looks normal, most traffic is fine, and one tenant is completely stalled. It reads like a partial outage of unclear origin —
ApproximateAgeOfOldestMessageis the metric that exposes it.
8. FIFO and dead-letter queues
A DLQ is the standard remedy for a poison message — and on FIFO it comes with a caveat AWS states explicitly.
⚠️ AWS advises against using a DLQ with a FIFO queue where strict ordering must never break (
aws-facts.md§7).
The reason follows directly from §7: when m1 moves to the DLQ, m2 through m4 proceed —
without m1 ever having been processed. The group's sequence now has a permanent hole. If those
messages were ledger entries or state transitions, the resulting state is wrong in a way that no
downstream process will detect.
You are choosing between two bad outcomes:
| Choice | Consequence |
|---|---|
| DLQ attached | Ordering guarantee broken at the gap. Throughput preserved |
| No DLQ | Ordering preserved. The group is blocked until the message expires at retention — up to 14 days |
Neither is good. Which is less bad is a business question, not a technical one — and the value of this section is knowing that you must ask it rather than accepting the default.
📌 For genuinely order-critical FIFO workloads, the usual answer is: attach a DLQ, alarm on it aggressively, and treat any DLQ arrival as a correctness incident requiring manual reconciliation — not as routine failure handling.
Two more FIFO DLQ facts:
- The DLQ must itself be a FIFO queue — types must match.
- On FIFO, the enqueue timestamp resets when a message moves to the DLQ (standard queues preserve it). So a FIFO DLQ message gets the full retention window from arrival (Module 11).
9. Consumer concurrency
Your effective parallelism is:
concurrency = min( number of ACTIVE message groups,
number of consumers,
platform limits )Every term is a real ceiling.
With Lambda, this is explicit: concurrent invocations are capped at
min(active message groups, MaximumConcurrency)
(aws-facts.md §8). Six active groups and a MaximumConcurrency of
10 gives you six. The autoscaler is not broken; it has nothing to scale into.
⚠️ A FIFO queue with 4 active message groups cannot use more than 4 concurrent consumers, whatever your scaling policy says. Adding workers produces idle workers and wasted long-poll requests (Module 14).
This is the second ceiling from §6's worked example, and it is the one that bites during a traffic spike: volume rises but the number of distinct active entities does not, so parallelism does not either.
10. Configuration reference
| Attribute | Values | Notes |
|---|---|---|
FifoQueue | true | Set at creation only. Cannot be changed later |
| Queue name | must end .fifo | Suffix counts toward the 80-character limit |
ContentBasedDeduplication | true/false | SHA-256 of the body only |
DeduplicationScope | queue / messageGroup | messageGroup required for high-throughput mode |
FifoThroughputLimit | perQueue / perMessageGroupId | perMessageGroupId enables high-throughput mode |
MessageGroupId | ≤ 128 chars, required per send | Alphanumerics plus punctuation |
MessageDeduplicationId | ≤ 128 chars | Required unless content-based dedup is on |
| In-flight limit | 120,000 | Same as standard (aws-facts.md §3) |
| Message groups per queue | no quota | Create as many as you like |
⚠️ The FIFO in-flight limit is 120,000, not 20,000. Older documentation and most blog posts say 20,000 (misconceptions.md §17).
⚠️ Per-message delay timers do not work on FIFO queues. Only the queue-level
DelaySecondsapplies.
⚠️ You cannot convert a queue between standard and FIFO. The name must change, so both producers and consumers change. Plan the migration as a dual-write and cutover, not a config flip.
11. Production considerations
The group key is the design. Spend real time on it. Too coarse costs throughput and reliability; too fine silently removes the guarantee you are paying for.
Content-based dedup with attribute-carried variation is a silent data-loss bug. If anything
distinguishing your messages lives in an attribute, use an explicit MessageDeduplicationId.
Head-of-line blocking presents as a partial outage. One tenant completely stalled while overall
metrics look healthy. Monitor ApproximateAgeOfOldestMessage, and consider per-group age tracking
in your own metrics if tenancy matters.
High-throughput mode's regional variation is a multi-Region hazard. Size against your smallest Region.
Active group count is your real concurrency ceiling, not total group count. Measure it — it usually surprises people, and it does not rise proportionally with traffic.
Deduplication is not idempotency. FIFO reduces one source of duplicates in one window. Your consumer still needs a guard (Module 09).
FIFO costs more. $0.50 per million requests versus $0.40 for standard
(aws-facts.md §9). Not usually decisive, but it is real at volume.
12. Common mistakes
"FIFO gives exactly-once processing." Why it's wrong: it means no duplicate sends within 5 minutes. Consumer redelivery after a crash is unaffected. Instead: idempotency, regardless of queue type.
One MessageGroupId for everything.
Why it's wrong: fully serial processing, a 300 TPS ceiling, and one bad message halts all work.
Instead: group by the entity whose ordering actually matters.
A random UUID as the group id. Why it's wrong: no two messages are ever ordered relative to each other. You have FIFO's cost and standard's semantics. Instead: if you genuinely need no ordering, use a standard queue and save the money.
Expecting ordering across groups. Why it's wrong: groups are independent by design; the interleaving is not preserved. Instead: if you need global order, you need one group — and you have accepted serial processing.
Content-based dedup where attributes carry the difference.
Why it's wrong: attributes are not hashed; the second message is silently discarded with a success
response.
Instead: explicit MessageDeduplicationId.
Attaching a DLQ to an order-critical FIFO queue without thinking. Why it's wrong: the DLQ move breaks the ordering guarantee you chose FIFO to obtain. Instead: decide consciously, and treat DLQ arrivals as correctness incidents.
Choosing FIFO "to be safe". Why it's wrong: you pay throughput, complexity, head-of-line risk and 25% more per request for a guarantee you may not need. Instead: justify it — name the two messages whose inversion would break something.
13. Real-world example
A bank ledger. Every transaction must apply to an account in order — a withdrawal must not be processed before the deposit that funds it.
The design
sqs.sendMessage(r -> r
.queueUrl(ledgerQueueUrl)
.messageBody(json.write(txn))
.messageGroupId(txn.accountId()) // ordering scope = one account
.messageDeduplicationId(txn.id())); // explicit — not content-basedTwo deliberate choices:
messageGroupId = accountId. Per-account ordering is exactly the requirement. Different accounts have no ordering relationship, so they run in parallel.- Explicit
messageDeduplicationId. They considered content-based dedup and rejected it: two legitimate transactions could have identical bodies ({"amount": 5000, "type": "DEPOSIT"}) and differ only in a message attribute. Content-based dedup would have silently dropped the second deposit.
That single decision is the difference between a working ledger and a customer who is quietly missing £50 with no error anywhere in the system.
The numbers
Accounts : 200,000
Peak : 8,000 txn/sec
Concurrently active : ~2,400 accounts at peak ← measured, not assumed
Processing time p99 : 180 ms
Region : eu-west-1
Concurrency needed (Little's Law):
8,000 × 0.180 = 1,440 concurrent slots
Available (active groups): 2,400 ✅ 1,440 < 2,400 — viable
Throughput:
8,000 ÷ 10 per batch = 800 calls/sec per action
High-throughput mode, eu-west-1 = 70,000 TPS ✅ 1.1% utilisedThe margin between 1,440 needed and 2,400 available is thinner than it looks — a change in customer behaviour that concentrates activity into fewer accounts reduces the ceiling without any change in message volume. They alarm on active group count for exactly this reason.
The counterfactual
Had they used messageGroupId = "ledger":
Groups : 1
Parallelism : 1 consumer
Throughput ceiling : 300 TPS (default mode)
Required : 8,000/sec
Shortfall : 26× — not viable at any consumer count
One poison message : blocks every account, indefinitelySame code, one string different, completely unworkable system.
What went wrong anyway
Six months in, a batch reconciliation job submitted 40,000 corrections for a single corporate account. That account's group serialised them at ~5/sec — over two hours of processing — while a message midway through kept failing on a stale exchange rate.
Symptoms: total queue depth normal. Overall throughput normal. One corporate customer's balance frozen for two hours. No alarm fired, because every queue-level metric was healthy.
The fix, in two parts:
- Immediate:
ApproximateAgeOfOldestMessagealarm, which would have caught it in minutes. - Structural: the reconciliation job now submits to a separate FIFO queue with its own consumers, so bulk corrections cannot occupy the same group as live transactions. A bulkhead (Module 24) — separating workloads with different shapes rather than different data.
🎯 The transferable lesson: head-of-line blocking is invisible in aggregate metrics. If per-tenant fairness matters, you need per-tenant visibility.
14. Interview questions
🟢 Beginner
What is a FIFO queue?
A queue type that guarantees messages within a message group are delivered in the order they were
sent, and that deduplicates repeated sends within a 5-minute window. The name must end .fifo.
What is MessageGroupId?
A required label on every FIFO send that declares which messages must be ordered relative to each
other. Messages sharing a group are processed one at a time in order; different groups run in
parallel.
🟡 Intermediate
What exactly does FIFO guarantee about ordering? Ordering within a message group, and nothing across groups. Within a group, the next message is not delivered until the current one is deleted or its visibility expires. The interleaving between two groups is not preserved.
Why is MessageGroupId required?
Without it FIFO would have to treat every message as ordered relative to every other, which means
one consumer processing serially. The group id declares where ordering constraints genuinely exist,
and by implication where SQS is free to parallelise.
What is the deduplication interval? Five minutes, fixed and not configurable. A repeated send with the same deduplication id inside that window is accepted and discarded. Outside it, the message is enqueued again.
🔴 Advanced
Why does AWS call FIFO "exactly-once processing" when duplicate processing is still possible?
Because the phrase describes the send side. FIFO does not introduce duplicates into the queue — a producer retry within the 5-minute deduplication window results in one message, not two.
It says nothing about the receive side. A consumer that processes a message and crashes before
DeleteMessage will receive it again when the visibility timeout expires, identically to a standard
queue. FIFO deduplicates enqueueing; it does not deduplicate execution.
The practical consequence is that idempotency is required on FIFO queues exactly as on standard ones, and teams that read "exactly-once" as a processing guarantee have shipped double-charges.
How does the choice of MessageGroupId affect throughput? Give a worked example.
It sets two independent ceilings.
Partition ceiling. SQS derives partitions from group ids; throughput is 300 TPS per action per partition in default mode. Few groups means few partitions means low throughput. One constant group caps you at roughly 300 TPS, or 3,000 msg/sec with batching.
Concurrency ceiling. Processing within a group is serial, so parallelism cannot exceed the number of concurrently active groups — not the total number of groups you have ever used.
Worked: 8,000 msg/sec with 180 ms processing needs 8,000 × 0.18 = 1,440 concurrent slots by
Little's Law. If only 200 accounts are active at once, your maximum parallelism is 200, giving
200 ÷ 0.18 ≈ 1,111 msg/sec — well short, regardless of how many consumers you run. The fix is not
more consumers; it is a finer-grained group key, or a different queue design.
A single message in a FIFO queue fails repeatedly. What is the blast radius?
Exactly one message group, entirely blocked, for approximately
maxReceiveCount × visibilityTimeout. Every message behind it in that group is undeliverable to any
consumer. Other groups are unaffected.
The blast radius is therefore a direct function of the group key: a constant group means total
outage; customerId means one customer. This is a reliability argument for fine-grained keys that
is independent of the throughput argument.
The presentation is the difficult part — aggregate metrics look healthy while one tenant is
completely stalled. ApproximateAgeOfOldestMessage is the metric that exposes it.
Why does attaching a DLQ to a FIFO queue break the guarantee you paid for? Because the DLQ move lets the blocked group proceed without the failed message having been processed. The sequence now has a permanent hole. For ledger entries or state transitions, the resulting state is wrong and nothing downstream will notice. The alternative — no DLQ — keeps ordering intact but blocks the group until retention expires, up to 14 days. Both are bad; the choice is a business decision.
⚫ System design
Design an order-sensitive system at 10,000 msg/s. Do you use FIFO? Justify with the numbers.
Work it in four steps.
1. Establish what ordering is actually required. Ask for the specific pair of messages whose inversion produces a wrong result. Usually the answer is per-entity — per order, per customer — not global. If nobody can name a pair, the requirement is inherited and the answer is standard.
2. Check feasibility.
10,000 msg/s ÷ 10 (batched) = 1,000 calls/sec per action
Default FIFO : ~3,000 msg/s batched ❌ 3.3× short
High-throughput : eu-west-1 70,000 TPS ✅ viable
smallest Regions 2,400 TPS ❌ not viableSo: feasible with high-throughput mode, in the right Regions only. Name that constraint explicitly — it is a multi-Region blocker.
3. Check concurrency, which is the ceiling people miss.
Little's Law: 10,000 × processing_time = required concurrent slots
At 100 ms : 1,000 slots → need ≥1,000 concurrently ACTIVE groups
At 500 ms : 5,000 slots → need ≥5,000 active groupsThen go and measure concurrently-active entities. If the workload has 50,000 customers but only 300 transacting in any given second, FIFO cannot reach 10,000 msg/s at 100 ms processing no matter what you provision.
4. Design the rest deliberately.
- Group key = the entity from step 1. Document the resulting blast radius.
- Dedup: explicit ids unless you are certain the body alone distinguishes messages.
- DLQ: attach it, alarm on depth > 0, and treat arrivals as correctness incidents requiring reconciliation — because the move breaks ordering.
- Visibility timeout from the p99, and
maxReceiveCountlow (3), because their product is how long one bad message blocks a group. - Bulkhead bulk work: batch jobs go to a separate queue so they cannot occupy the same group as live traffic.
The answer I would give: split the traffic. Route the genuinely order-sensitive subset to FIFO with a fine-grained group key, and leave the rest on standard. Applying the strictest requirement to 100% of traffic when it applies to 5% is the most common and most expensive error in this design.
Follow-ups to expect: "What if active group count is too low?" — make the key finer if the ordering requirement permits, or accept lower throughput, or reconsider whether ordering is needed. "How would you migrate from standard?" — new queue (the name must change), dual-write, drain, cut over consumers, retire the old queue.
15. Summary
- Ordering is guaranteed within a message group, never across groups.
MessageGroupIdsets three things at once: ordering scope, parallelism ceiling, and failure blast radius.- "Exactly-once processing" means exactly-once enqueueing. Consumer redelivery is unaffected; idempotency is still required.
- Deduplication is a fixed 5-minute window on the send side. A deduplicated send returns success.
- Content-based dedup hashes the body only — attribute-carried differences are silently discarded.
- Throughput is per-partition, derived from group ids: 300 TPS per action by default, up to 70,000 in high-throughput mode, region-dependent.
- Concurrency is capped by concurrently active groups, not total groups.
- Head-of-line blocking: one failing message blocks its whole group for roughly
maxReceiveCount × visibilityTimeout, and is invisible in aggregate metrics. - A DLQ breaks FIFO's ordering guarantee by design. Decide consciously.
- In-flight limit is 120,000, not 20,000.
← Previous: 06 — Standard Queues · Index: Course home · Next: 08 — Visibility Timeout →