Learning/AWS SQS/07 — FIFO Queues
Advanced 45 min read

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 MessageGroupId as 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 → SHIPPED is not the same as SHIPPED → CREATED → PAID
  • A change feed — apply set name = "Bob" then set 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 MessageGroupId at 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"]
    end

Reading 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 B1 comes after A1 merely 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 ~~~ RECV

Reading 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).

Java
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 setsMeaning
Ordering scopeWhich messages are ordered relative to each other
Parallelism ceilingYou cannot process more concurrently than you have active groups
Failure blast radiusOne 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 keyGroupsParallelismVerdict
"orders" (one constant)11❌ Fully serial. 300 TPS ceiling. One bad message halts everything
customerId~200,000High✅ Usually correct — per-customer ordering, broad parallelism
region55⚠️ Ordering is probably not per-region; parallelism is poor
UUID.randomUUID()= message countMaximal❌ No two messages are ever ordered. You bought FIFO's cost for standard's behaviour

📌 The right key is the entity whose ordering actually mattersaccountId, 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.

⚠️ MessageGroupId on 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

Java
.messageDeduplicationId(transactionId)   // you supply the identity

Option B — content-based deduplication

Enable it on the queue and SQS computes a SHA-256 of the message body.

Java
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:

  1. The window is exactly 5 minutes and is not configurable.
  2. 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.
  3. 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 batching300 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 groupNon-batched TPSBatched (×10) msg/sec
us-east-1, us-west-2, eu-west-170,000700,000
us-east-2, eu-central-119,000190,000
ap-south-1, ap-southeast-1/2, ap-northeast-1, eu-south-29,00090,000
eu-west-2, sa-east-14,50045,000
All other Regions2,40024,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.

Java
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 this

That 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 MessageGroupId affect 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: m2

Reading 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 keyOne poison message blocks
"orders"Everything. Total outage
customerIdOne customer. Others unaffected
region20% 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 — ApproximateAgeOfOldestMessage is 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:

ChoiceConsequence
DLQ attachedOrdering guarantee broken at the gap. Throughput preserved
No DLQOrdering 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

AttributeValuesNotes
FifoQueuetrueSet at creation only. Cannot be changed later
Queue namemust end .fifoSuffix counts toward the 80-character limit
ContentBasedDeduplicationtrue/falseSHA-256 of the body only
DeduplicationScopequeue / messageGroupmessageGroup required for high-throughput mode
FifoThroughputLimitperQueue / perMessageGroupIdperMessageGroupId enables high-throughput mode
MessageGroupId≤ 128 chars, required per sendAlphanumerics plus punctuation
MessageDeduplicationId≤ 128 charsRequired unless content-based dedup is on
In-flight limit120,000Same as standard (aws-facts.md §3)
Message groups per queueno quotaCreate 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 DelaySeconds applies.

⚠️ 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

Java
sqs.sendMessage(r -> r
        .queueUrl(ledgerQueueUrl)
        .messageBody(json.write(txn))
        .messageGroupId(txn.accountId())        // ordering scope = one account
        .messageDeduplicationId(txn.id()));     // explicit — not content-based

Two 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% utilised

The 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, indefinitely

Same 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: ApproximateAgeOfOldestMessage alarm, 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 viable

So: 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 groups

Then 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 maxReceiveCount low (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.
  • MessageGroupId sets 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