R9 — Messaging: queues, pub/sub, Kafka and SQS — keeping events ordered and processed once

R4 made sure the record of an event survives; this section is about the pipe it travels through. It is built in four parts. First the ideas every messaging system shares: why you send a message instead of making a call, the two shapes — a queue for one worker, a topic for every subscriber — and what happens to a message once it has been read. Then Kafka, from the parts of a cluster to the settings that decide whether events stay ordered and take effect once. Then Amazon SQS, given the same treatment. And finally the two side by side, with the questions that decide between them.

Messaging fundamentals

Why send a message instead of making a call

Start from the situation. A customer submits a payment. The API could do everything inline — validate, debit, post to the ledger, email the customer, update the dashboard — and make the customer wait for all of it. Or it could record the intent, reply "accepted", and let the rest happen in the background. The second is asynchronous communication, and it buys four things: throughput (the request thread is freed at once), scalability (background work scales separately from the API), fault tolerance (a slow or dead downstream no longer fails the request) and loose coupling (the API stops knowing who does the rest). That last one is inversion of control: the sender stops calling, and the receivers start listening.

There are two ways to hand the work off.

Message queue: one message, one consumer

A message queue is point-to-point. A producer puts a message on the queue; exactly one consumer takes each message. If you run five workers on the same queue, they compete, and each message goes to whichever worker is free.

That shape gives you three things:

The payments case: the payment service needs a payment executed against a rail, so it puts ExecutePayment on a queue read by the payment mediator. One worker must do it, not three. That is a command in R0's sense — "please do this", addressed to one service, which can refuse or fail — and a queue is its natural transport.

Who owns the format. On a queue, the consumer usually defines the message format. The worker is the one with a contract to fulfil ("send me these fields and I will execute the payment"), and producers conform to it, the way callers conform to an API.

Why not just a database table? You can mimic a queue with a jobs table: insert a row, have workers poll it, mark rows done. It works, and at low volume it is fine. It is heavier than a queue: every message is a durable row with indexes and write-ahead logging, load leveling is your problem (row locking, SELECT ... FOR UPDATE SKIP LOCKED, claiming and timing out), and every poller holds database connections. The outbox in R4 is exactly a table used as a queue for one narrow purpose — surviving the gap between commit and publish — and then handing off to a real broker.

Pub/sub: one event, every subscriber

Pub/sub inverts the relationship. A publisher writes an event — a fact about a state change, "payment submitted" — to a topic. Any number of subscribers each receive their own copy and react independently. The publisher does not know or care who is listening.

The payments case: the payment service publishes PaymentSubmitted. The ledger posts entries, notifications emails the customer, the read model updates its dashboard row. Tomorrow fraud analytics subscribes, and the payment service changes nothing. That is R0's test for an event: if nobody subscribed, publishing it would still be correct, because it is already true.

The shapes this supports:

Shape What it looks like Payments example
Fan-out One publisher, many subscribers PaymentSubmitted → ledger, notifications, read model
Fan-in Many publishers, one subscriber Card, wire and ACH services all publish to one topic the ledger reads
Many-to-many Many of both Every payment-type service publishes; ledger, fraud and reporting all subscribe

Who owns the format. Here the publisher defines the format, because it is announcing a fact about its own domain, and it cannot negotiate with subscribers it does not know exist. That is why event schemas need versioning discipline: a breaking change hits consumers you never met.

The limits of pub/sub

Before reading further, play with it. Send a few messages through the queue and watch each one land on exactly one consumer; then publish to the topic and watch every subscriber get its own copy. Notice that adding a consumer to the queue splits the work, while adding a subscriber to the topic duplicates it.

Live · Queue vs pub/sub

Side by side

Message queue Pub/sub topic
Who gets each message Exactly one consumer Every subscriber, a copy each
Adding a consumer Splits the work (competing consumers) Adds another independent reader
What the message is Usually a command: do this An event: this happened
Who defines the format The consumer The publisher
Main benefit Load leveling, decoupled workers Fan-out without the publisher knowing
Main cost At-least-once, so idempotent workers Eventual consistency, hard to trace and test
Payments example ExecutePayment to the payment mediator PaymentSubmitted to ledger, notifications, read model
Typical products SQS, RabbitMQ queue Kafka topic, SNS, RabbitMQ fanout exchange

What happens after a message is read: queues and logs

Who a message is for is the first split — the one interviewers usually ask first, because it decides the shape of the design before any broker is named. The second, just as important, is what happens to a message after it is read — and most confusion about Kafka comes from imagining it as a queue when it is really a log.

A queue (SQS, RabbitMQ) is a to-do pile. A consumer takes a message, works on it, and when it acknowledges, the message is deleted. The queue's job is handing out work exactly once-ish, and it shrinks as work gets done.

A log (Kafka) is an append-only notebook. Producers write lines at the end; the lines are never handed out and never removed on read. A consumer just remembers which line number it has read up to. Ten consumers can read the same line independently, and a consumer can go back and re-read from line 400 tomorrow.

That one difference explains almost everything else:

Queue (SQS) Log (Kafka)
After a consumer acknowledges Message is deleted Message stays; only the consumer's position moves
Multiple independent consumers Need a copy each (SNS fan-out, or one queue each) Natural — each group has its own position
Re-reading yesterday's events Impossible Normal: reset the offset
Retention Until consumed, max 14 days Time or size based, days to forever
Scaling unit Message groups, effectively unbounded Partitions, sized up front

Concepts every broker shares

Whatever the product, the same handful of ideas decide how a messaging system behaves. Learn them once here; the Kafka and SQS parts show how each product implements them.

Concept What it means Kafka SQS
Producer and consumer The sender and the receiver; they never talk to each other, only to the broker Producer client, consumer client SendMessage, ReceiveMessage
Acknowledgement The consumer telling the broker it has finished with a message Committing an offset — a bookmark DeleteMessage — the message is removed
Delivery guarantee How often a message can arrive. Every practical broker is at-least-once by default: a consumer that crashes before acknowledging gets the message again At-least-once; exactly-once only inside Kafka with transactions At-least-once (standard and FIFO)
Ordering key The value that groups messages which must stay in order; order is promised within a key, never globally Message key → partition MessageGroupId (FIFO only)
Competing consumers Several workers sharing one stream of work, each message going to one of them Consumers in one group, one per partition Workers polling the same queue
Independent subscribers Each interested service getting its own copy One consumer group per service One queue per service, fed by SNS
Retention How long a message is kept, and whether it can be read again Time- or size-based, replayable Until deleted, at most 14 days, no replay
Dead-letter queue Where a message goes after it has failed too many times You build it: a .dlq topic Built in: a redrive policy
Lag / backlog How far consumers are behind producers — the number to alert on Consumer lag, per consumer group per partition Queue depth, age of oldest message

Dead-letter queues, briefly. Every system needs somewhere for messages that keep failing. Two rules hold whichever broker you use — the DLQ needs an alarm on depth, because a silent DLQ is just a slower way of losing data, and it needs a documented way to replay from it once the bug is fixed.

And one rule follows from at-least-once delivery on every broker: the consumer must be idempotent — deduplicate on a business event ID in the same transaction as the effect (R3, R4's inbox). The broker can reduce duplicates; it can never remove them.

Message brokers

A message broker is the server that sits in the middle and does more than store bytes: it validates, routes, stores, and gives guaranteed delivery — it holds a message until a consumer has acknowledged it.

RabbitMQ is the classic example, and its routing model is worth knowing because it shows both shapes in one product. Producers publish to an exchange, not a queue, and the exchange decides which queues get a copy:

Exchange type Routing rule Result
Direct Exact match on routing key Point-to-point: payment.execute → one queue
Fanout Ignores the key, copies to every bound queue Pub/sub: every subscriber queue gets the event
Topic Wildcard match on a dotted key (payment.*.failed) Selective pub/sub: subscribers pick the events they want

Under the hood, pub/sub in RabbitMQ is a queue per subscriber fed by an exchange — the same shape as SNS to SQS.

A broker deliberately stays a "dumb pipe": routing by queue, exchange or key, while transformations and business decisions stay in the services. An enterprise service bus puts routing rules, format transformation and orchestration in the middleware itself. Which of the two should hold your integration logic is an architectural choice, and R1's section on architecture styles compares them.

MQTT is the lightweight end of the family: a pub/sub protocol over TCP designed for IoT devices and unreliable networks, with small headers and a broker routing messages between publishers and subscribers. Use it for sensors, parking lots, traffic lights — not for a payment backbone. One design note carries over: a single broker is a single point of failure, so production MQTT deployments distribute the broker, exactly as Kafka replicates partitions across brokers.

Decide the shape before the product: a command for one worker goes on a queue, a fact for anyone who cares goes on a topic. Then pick the broker whose delivery and retention guarantees match — which is the comparison the next sections make in detail.

Where Kafka and SQS sit. Kafka gives you pub/sub naturally, because each consumer group keeps its own offset on the same log, and gives you a work queue within a group, because each partition goes to exactly one member. SQS is a queue; to get pub/sub you put SNS in front and fan out to one SQS queue per subscriber. The rest of this section is about those two in production.

Kafka

Kafka is a distributed, replicated, append-only log. Before the tuning knobs, the parts and the mental model — because most confusion about Kafka comes from imagining it as a queue when it is really a log.

The components of a Kafka cluster

Component What it is What to remember
Broker One server in the cluster. It stores partitions on its own disk and serves reads and writes for the partitions it leads A cluster is a set of brokers; adding brokers adds storage and throughput
Controller The component that decides where every partition lives and which copy leads — a small KRaft quorum in current Kafka (ZooKeeper in older versions) It reassigns leaders when a broker fails
Topic A named stream, like a table name: payments.submitted Created with a partition count and a replication factor
Partition A topic is split into partitions, and each partition is a separate ordered log The unit of both ordering and parallelism
Replica: leader and followers Each partition is copied to replication factor brokers. One copy, the leader, takes all writes and by default all reads; followers copy it Two copies of one partition never share a broker
In-sync replicas (ISR) The followers that are caught up with the leader acks=all waits for the ISR; only an in-sync follower can be promoted
Offset The line number of a record within a partition Consumers commit offsets to record progress
Producer The client that writes records. Its partitioner picks the partition: hash(key) % partitions Same key, same partition, always — decided on the client, not the broker
Consumer The client that polls records from partitions and tracks its position It reads from each partition's leader
Consumer group A set of consumers sharing the work of a topic. Kafka assigns each partition to exactly one consumer in the group A group can never have more useful consumers than partitions; each group has its own position, so groups never compete
Group coordinator The broker that manages one consumer group: tracks members by heartbeat, runs rebalances, and bumps the group's generation on each one A consumer on an old generation can no longer commit offsets
__consumer_offsets The internal topic where committed offsets are stored, per group and partition A group's lag on a partition = log-end offset − that group's committed offset

How Kafka works

Writing: keys, partitions and ordering

Ordering is per partition. Kafka guarantees offset order within one partition and nothing across partitions. A partition is consumed by exactly one consumer in a group at a time. So the key you choose is the ordering design: key by account ID and all of that account's events are ordered; key by payment ID and only that payment's events are.

A broker never chooses the partition — the producer does. The producer's partitioner hashes the key (murmur2(key) % partitions) on the client, looks up which broker leads that partition in the metadata it already holds, and sends the record straight to that leader. A record with no key goes to whichever partition the sticky partitioner is currently filling.

Why ordering is per partition, in one picture. Six messages for accounts A and B, keyed by account, two partitions:

partition 0:  A1  A2  A3        ← consumer 1 reads these in order
partition 1:  B1  B2  B3        ← consumer 2 reads these in order

A's events are strictly ordered. B's events are strictly ordered. But nothing anywhere orders A2 against B1, because they are in different logs being read by different processes at different speeds. If you need those two ordered relative to each other, they must share a key — and that means they share a partition, which means they share a consumer, which caps your throughput. Ordering and parallelism are the same dial turned in opposite directions, and picking the key is how you set it.

Replication and durability

The vocabulary becomes concrete once you see where things physically live. A cluster is a set of brokers. Creating a topic means choosing two numbers: how many partitions it has, and its replication factor, the number of copies of each partition. For every partition the cluster's controller picks one copy as the leader, which takes all writes and by default all reads, and places the followers on other brokers. It spreads leaders across brokers so that no single broker carries most of the traffic, and it never puts two copies of one partition on the same broker, because one failure would then lose both.

What happens next decides durability:

Durability on the broker. acks=all is only as strong as min.insync.replicas. With replication factor 3 and min.insync.replicas=2 you survive one broker loss and still refuse writes when two are down — refusing is correct for money. With min.insync.replicas=1 you have acks=all in name only.

Reading: consumer groups and offsets

On the reading side, one rule decides everything: within a consumer group, each partition is assigned to exactly one consumer.

Consumers in the group vs partitions What happens
Fewer consumers than partitions Every partition is read, but some consumers own several and do more work
Equal One partition each — the maximum useful parallelism
More consumers than partitions The extras sit idle; they are only useful as warm standbys
A second consumer group Gets its own assignment, its own copy of every event and its own committed offsets — groups never compete

What "committing an offset" really means. The consumer is saying "I have finished everything up to line 812." It is a bookmark, not an acknowledgement of an individual message. Two consequences juniors get bitten by: committing before the work finishes means a crash silently skips messages, and processing messages out of order within a batch means a commit can cover work that never completed.

Consumer lag is defined per consumer group, per partition. Because every group commits its own offsets, one partition read by three groups has three independent lags:

lag(group, partition) = log-end offset of the partition − committed offset of that group on that partition

There is no such thing as "the lag of a partition" or "the lag of a topic" — only how far one group is behind on one partition. "What to monitor" below covers how it is measured and alerted on.

Step through the cluster below. The first tab builds it: five brokers, three topics of three partitions each, replication factor 2, with leaders solid and followers dashed. The second follows one producer's events from key to partition to replica, then kills a broker. The third adds two consumer groups to T1, and the fourth changes the number of consumers in a group.

Producer settings that decide correctness

enable.idempotence=true            # dedupes producer retries per partition
acks=all                           # wait for in-sync replicas
max.in.flight.requests.per.connection=5   # safe only with idempotence on
retries=Integer.MAX_VALUE
delivery.timeout.ms=120000           # the real bound on retries (default: 2 minutes)

Without idempotence, a retry after a lost ack writes the message twice and silently reorders it. This is the most common real-world source of duplicate payment events.

What producer idempotence covers, and what it does not. Producers write to the leader of a partition on the brokers; the cluster stores each partition as a replicated, append-only log; consumers read from that log and track their own offsets. Producers publish through the Kafka client library, which retries a send until the broker acknowledges it or the delivery timeout expires, at which point the error goes back to the application. With idempotence enabled, the client tags each batch with a producer ID and a per-partition sequence number, so when it retries a batch the broker already wrote, the broker drops the duplicate. That protection covers only the client's own retries. Duplicates created by the application — including a resend after a timed-out send — or by a producer restart, redeliveries after a consumer crash or rebalance, and deliberate replays all reach the consumer. So consumers deduplicate on a business event ID carried in the payload: in the same transaction as the effect when the effect is a database write, and as the idempotency key when it is a call to another system.

Delivery timeout: the bound on every retry. With retries effectively unlimited, delivery.timeout.ms is the setting that actually decides how long the producer keeps trying. It is the upper bound on the time between send() returning and the callback reporting success or failure, and it covers everything in between: waiting in the batch buffer (linger.ms), waiting for a free in-flight slot, every individual request (request.timeout.ms, 30 seconds by default), and every retry with its backoff. The client rejects a value smaller than linger.ms + request.timeout.ms.

Two neighbouring settings are easy to confuse with it:

Setting Bounds Default
max.block.ms How long send() itself may block before returning — waiting for metadata, or for space in a full buffer.memory 60 s
request.timeout.ms How long the client waits for the broker to answer one request before retrying it 30 s
delivery.timeout.ms The whole delivery, all retries included, after send() has returned 120 s

A delivery timeout is an unknown outcome, not a failure. The callback gets a TimeoutException, but the record may well have been written: the last ack could have been lost on the way back. The application must not report the payment as failed. If it simply resends, a record that was in fact written is now in the log twice, with a new sequence number that idempotence will not catch — exactly the application-level duplicate described above. With a transactional producer, a delivery timeout aborts the current transaction.

How to size it:

Too short Too long
Ordinary leader elections and broker restarts, which take seconds, surface as errors instead of being absorbed by retries A genuine outage is reported late, so callers wait and alerts fire late
Every spurious timeout invites the application to resend, creating duplicates Records pile up in buffer.memory; once it is full, send() blocks for max.block.ms and then throws

Setting the delivery timeout for a payment producer

Transactions: exactly-once inside Kafka

Transactions and EOS. A producer with a transactional.id can write messages to several partitions and commit consumer offsets atomically, giving exactly-once within Kafka for read-process-write: wrap consume-transform-produce in a transaction and commit the consumer offsets with sendOffsetsToTransaction, so offsets and output move together. Readers must set isolation.level=read_committed. It works well for stream processing between topics. Two limits to name: it does not extend to your database or to an external rail, and it costs latency. For a payment that leaves the building, keep at-least-once plus idempotency at the boundary and skip the complexity.

Consuming safely

// Spring Kafka — the shape of a safe consumer
@KafkaListener(topics = "payments.submitted", groupId = "ledger")
public void handle(ConsumerRecord<String, PaymentEvent> record, Acknowledgment ack) {
    // 1. dedupe + work, in ONE transaction  (R4's inbox)
    boolean fresh = inbox.recordIfNew(record.value().eventId(), "ledger");
    if (fresh) {
        ledger.post(record.value());
    }
    // 2. only now move the bookmark
    ack.acknowledge();
}

Three rules hide in those eight lines, and they are the ones worth repeating in an interview: manual acknowledgement (enable.auto.commit=false), dedupe and work in the same transaction, and acknowledge last. Everything else in this section is tuning around that shape.

Consumer settings and the commit discipline. Turn off auto-commit. Process the batch, then commit — that gives at-least-once, which is the correct default for payments because the alternative loses messages. Keep max.poll.records small enough that a batch finishes well inside max.poll.interval.ms; the usual production incident is slow processing triggering a rebalance, which triggers reprocessing, which makes processing slower.

Rebalancing

Rebalance. Triggered by a consumer joining, leaving, crashing (missed heartbeat), or exceeding max.poll.interval.ms. Under the classic eager protocol, everyone stops and all partitions are revoked; cooperative sticky assignment moves only the partitions that must move. During the pause, in-flight work may be reprocessed by the new owner — which is why consumers must be idempotent and why long processing inside poll() causes a rebalance storm.

Key configs: session.timeout.ms (failure detection), heartbeat.interval.ms (roughly a third of it), max.poll.interval.ms (the real cause of surprise rebalances when processing is slow).

A rebalance is the group agreeing on a new assignment. One broker acts as the group's coordinator: it tracks members by their heartbeats and bumps a generation number on every rebalance, so a consumer still on an old generation can no longer commit offsets. When membership changes, every member rejoins, one member is chosen as group leader and computes the assignment, and the coordinator distributes it.

Two things make this expensive:

The rebalance storm is a feedback loop. A slow dependency stretches a batch past max.poll.interval.ms, so the consumer is evicted. Its partitions move to consumers that must reprocess the uncommitted batch on top of their own work, so they miss the deadline too. Each eviction and each rejoin is another rebalance, the group spends its time rebalancing instead of reading, and lag climbs. Rolling deploys cause the same churn: every pod that leaves and every pod that joins is a rebalance.

Step through the walkthrough below. It shows a consumer joining under the eager protocol and then under the cooperative one, a graceful leave against a crash, and a storm developing from one slow batch — then what breaks it.

Static membership stops deploy-time churn. Set group.instance.id per pod: a restarting consumer rejoins with its previous assignment instead of triggering a full reshuffle, provided it returns inside session.timeout.ms. Combined with cooperative sticky assignment, this is what makes rolling deploys of a consumer fleet non-disruptive — a direct tie to the zero-downtime section.

Keeping a consumer group stable

Partitions over time

Partition count is a one-way door. You can add partitions, never remove them, and adding breaks key-to-partition stability. Size for peak throughput and for the maximum consumer parallelism you will ever want, since consumers beyond the partition count sit idle. Err high within reason; thousands of partitions per broker have their own cost.

Repartitioning. Adding partitions changes the key-to-partition mapping for new messages while old ones stay put, so per-key ordering breaks across the boundary. The safe path is a new topic with the target partition count, dual-write or mirror, let consumers drain the old, then cut over.

Hot partition. One large corporate account saturates its partition. Options: sub-key into a bounded fan-out (account:0..n) while keeping all of one account's related events together, throttle that producer, or route whale accounts to a dedicated topic. State the trade-off plainly — any fan-out weakens ordering, so it must follow the business grain.

When a message keeps failing

Poison message. In an ordered stream you cannot skip. Park the key: stop consuming that key, move the message to a DLQ with full context, alert, and keep the rest of the partition moving only if ordering allows. Skipping silently is how payments get applied out of order.

Retries without blocking. Blocking retry inside the consumer stalls the partition. The common pattern is tiered retry topics (5s, 1m, 10m) with a final DLQ — but note that moving a message to a retry topic reorders it relative to its key. For payments, that is usually unacceptable, so prefer parking the key: record the key as blocked, stop consuming it, alert, and let the rest of the partition proceed only if your ordering requirement is per-key rather than global.

A dead-letter topic. Kafka has no built-in DLQ, so you build it: catch, publish to payments.submitted.dlq with the original headers plus the error, and move on — or, where ordering matters, park the key as above.

Resetting a consumer group's offsets

Because progress is only a committed offset per group and partition, you can move it: rewind to reprocess after fixing a bug, skip past a poison record, or start a new group from a point in time. The standard tool is kafka-consumer-groups.sh --reset-offsets.

Target Moves the group's committed offset to
--to-earliest / --to-latest The oldest retained record / the end of the partition
--to-offset 1500 An exact offset
--shift-by -100 / --shift-by 1 The current committed offset, moved back or forward
--to-datetime 2026-10-01T00:00:00.000 The first record at or after that time, looked up by record timestamp
--by-duration PT2H The first record from two hours ago
--from-file offsets.csv Per-partition offsets listed in a file

The scope is chosen separately: --topic payments.submitted resets every partition of the topic, --topic payments.submitted:0,2 only partitions 0 and 2, and --all-topics everything the group has committed.

# 1. Preview: rewind group 'ledger' on partitions 0 and 2 to midnight UTC on 1 October
kafka-consumer-groups.sh --bootstrap-server broker:9092 --group ledger \
  --topic payments.submitted:0,2 --reset-offsets \
  --to-datetime 2026-10-01T00:00:00.000 --dry-run

# 2. Apply it — only while the group has no running consumers
kafka-consumer-groups.sh --bootstrap-server broker:9092 --group ledger \
  --topic payments.submitted:0,2 --reset-offsets \
  --to-datetime 2026-10-01T00:00:00.000 --execute

# Skip exactly one poison record on partition 1
kafka-consumer-groups.sh --bootstrap-server broker:9092 --group ledger \
  --topic payments.submitted:1 --reset-offsets --shift-by 1 --execute

The group must be inactive. The tool refuses to reset a group that still has live members, because a running consumer would overwrite the new offsets with its next commit. Stop or scale the consumers to zero, reset, check with --describe, then restart them.

Two other ways to move offsets, for when a script is not enough:

And remember the setting that resets offsets without anyone asking: auto.offset.reset decides where a group starts when it has no committed offset or its offset is out of range — earliest reprocesses everything retained, latest silently skips to new records.

Resetting offsets safely in a payments system

What to monitor

Measuring consumer lag. Lag belongs to a (consumer group, partition) pair: the partition's log-end offset minus that group's committed offset on it. Strictly, a consumer can read only up to the high watermark, and with isolation.level=read_committed only up to the last stable offset, which excludes records of open transactions; monitoring tools use whichever limit applies.

Example — partition T1·P0 holds records up to offset 1,000:

Consumer group Committed offset on T1·P0 Lag
ledger 998 2
fraud 640 360
reporting 1,000 0

Same partition, three lags, and a slow fraud group has no effect on ledger.

Where to read it:

Lag beyond retention is data loss for that group. If a group falls further behind than the topic's retention, the records it has not read are deleted. Its next fetch is out of range, and auto.offset.reset decides whether it jumps to the earliest remaining record or to the latest — silently skipping data either way.

Alerting on lag

What to monitor. The thresholds below are rules of thumb to start from; tune them to your own baseline and SLOs.

Metric What it measures Good value Bad value
Consumer lag in time, per group per partition How many seconds of records a group still has to process on each partition A few seconds, flat or saw-toothed, well inside the processing SLO Growing steadily, beyond the SLO, or heading towards the topic's retention
Consumer lag in messages, per group per partition Log-end offset minus the group's committed offset Near zero, or a stable sawtooth that drains One partition climbing while the others stay flat
Committed-offset progress (time since last commit), per group per partition Whether a group is actually finishing work, not just connected Advancing every few seconds while records arrive Unchanged for minutes while lag grows — the "healthy but stuck" consumer
Poll interval vs max.poll.interval.ms How long each batch takes between polls Well under the limit, say below half Approaching the limit — evictions and a rebalance storm follow
Rebalance rate, per group How often the group reassigns partitions Only during deploys and scaling Repeated rebalances outside deploys, or a rising trend
Producer error rate and delivery timeouts Sends that fail or exhaust delivery.timeout.ms Zero, or isolated blips during broker restarts A steady trickle — usually an unhealthy broker or ISR
Producer request latency (p99) Time for a produce request to be acknowledged with acks=all Stable, tens of milliseconds Rising towards seconds, or towards the delivery timeout
Under-replicated partitions Partitions with fewer in-sync copies than the replication factor 0 Above 0 for more than a short blip
Partitions under min.insync.replicas Partitions that will refuse acks=all writes 0 Any — producers are failing on those partitions
Offline partitions Partitions with no leader at all 0 Any — those partitions are unavailable
ISR shrinks and expands Followers dropping out of and rejoining the in-sync set Near zero outside maintenance Frequent flapping — an overloaded broker, disk or network
Unclean leader elections Out-of-sync replicas promoted to leader 0, always Any — committed records may have been lost
Active controller count Brokers acting as controller, across the cluster Exactly 1 0 or more than 1
Broker disk usage Space used by partition logs Clear headroom, growth balanced by retention deletes Approaching full, or growing faster than retention removes data
Request handler idle ratio How much spare capacity brokers' request threads have Comfortably idle, above roughly 30% Consistently low, below roughly 20% — the broker is saturated
DLQ or dead-letter topic depth Messages parked after repeated failure 0, or rare and quickly handled Growing, or non-zero with nobody looking

Rising rebalance frequency is an early warning for almost everything else, and lag must always be read per consumer group and per partition — one lagging partition is invisible in an average.

Problems seen in production

Most Kafka incidents reported publicly are not exotic. They are the mechanisms above failing in a handful of recurring ways.

Problem What happens The defence
Healthy-looking consumers that are not consuming Cloudflare described consumers whose health checks passed because they could reach the brokers, while they had stopped committing offsets and lag kept growing until someone restarted them by hand Judge consumer health by committed offsets advancing on every assigned partition, not by connectivity
Unclean leader election Datadog described an out-of-sync replica being elected leader; when the old leader rejoined, records were truncated and lost. It was the default before Kafka 0.11 Keep unclean.leader.election.enable=false, replication factor 3 with min.insync.replicas=2, and alert on under-replicated partitions
ISR churn Followers repeatedly fall out of and rejoin the in-sync set — an early sign of an overloaded broker or disk, before any write fails Alert on ISR shrinks and expands and on UnderReplicatedPartitions
Message-size limits out of step Raising the maximum message size on producers before brokers and consumers can accept it stalls the pipeline with RecordTooLargeException (Datadog) Change size limits in a planned order across consumers, brokers and producers
Consumer memory blow-ups Larger messages times many assigned partitions — client libraries such as librdkafka keep a fetch queue per partition — can exhaust a consumer's memory (Datadog) Size fetch limits and partitions per consumer together
Committed offsets expiring Before Kafka 2.0, committed offsets were kept for only a day; a group idle over a weekend lost them and auto.offset.reset sent it to the earliest record (mass reprocessing) or the latest (skipped data). The default became 7 days (KIP-186), and since 2.1 the clock starts only once the group is empty (KIP-211) Know offsets.retention.minutes; alert on groups with no members; never rely on auto.offset.reset for correctness
One poison record stalls a partition A malformed record that always throws is retried forever, and everything behind it on that partition waits — widely reported as "one bad payload took down our consumer group" Bounded retries, then park the key or send it to a DLQ (above)
Rebalance storms and hot partitions Covered above: slow batches evicting consumers in a loop, and one large account saturating one partition Smaller polls, slow work off the poll thread, static membership, cooperative assignment; sub-keying or dedicated topics
Lag beyond retention A consumer that falls further behind than the topic's retention loses the unread records outright Alert on lag in time well before it reaches retention

Read more: Cloudflare — automatic restarts for unhealthy Kafka consumers, Datadog — lessons learned from running Kafka, KIP-186 and KIP-211.

Amazon SQS

SQS is AWS's fully managed message queue. There is no cluster to run, size or patch: you create a queue, send to it and receive from it.

Why SQS is a queue

Everything about SQS follows from one design decision: a message is work for one consumer, and acknowledging it deletes it.

That is the to-do pile from the fundamentals, exactly. To give several services their own copy, you put SNS in front and give each its own queue.

The parts of SQS

Part What it is What to remember
Queue The managed store of messages. Standard queues are at-least-once with best-effort ordering and near-unlimited throughput; FIFO queues add ordering per message group and deduplication on send Choose the type at creation; it cannot be changed
Producer Any client calling SendMessage (or SendMessageBatch, up to 10 at a time) On FIFO it also supplies a group ID and a deduplication ID
Consumer Any client calling ReceiveMessage — it pulls, up to 10 messages per call Use long polling (WaitTimeSeconds up to 20 s) so empty queues do not burn requests
Receipt handle A token returned with each received message, needed to delete it or extend its lease It identifies this delivery, not the message
Visibility timeout How long a received message stays invisible to other consumers — 30 s by default, up to 12 hours Must outlast the work; extend it with ChangeMessageVisibility
DeleteMessage The acknowledgement: removes the message permanently Delete only after the effect is durable
Retention How long an undeleted message is kept — 4 days by default, 1 minute to 14 days After that it is dropped, processed or not
Dead-letter queue and redrive policy A second queue that receives messages received more than maxReceiveCount times Built in; alarm on its depth, redrive after the fix
MessageGroupId (FIFO) The ordering and parallelism unit, like a Kafka key One batch per group in flight at a time
MessageDeduplicationId (FIFO) Suppresses duplicate sends within a 5-minute window Send an explicit ID rather than content-based dedup
SNS topic Not part of SQS, but its partner for fan-out: one publish, one copy per subscribed queue SQS's way of doing pub/sub

How SQS works

A message's life is a lease, not a read:

  1. Send. The producer sends; SQS stores the message redundantly across availability zones.
  2. Receive. A consumer receives it; the message becomes invisible for the visibility timeout, and the receive count goes up by one.
  3. Delete. The consumer finishes and deletes it with the receipt handle. The message is gone.
  4. Or it comes back. If the consumer crashes, or is still working when the timeout expires, the message becomes visible again and another consumer receives it — a duplicate delivery.
  5. Or it parks. Once the receive count passes maxReceiveCount, SQS moves the message to the dead-letter queue.
Standard queue FIFO queue
Delivery At-least-once; duplicates are a documented property At-least-once processing; duplicate sends suppressed for 5 minutes
Ordering Best effort only Strict within a message group
Parallelism Any number of consumers on any message Across groups; one batch per group in flight
Throughput Effectively unlimited Quota-limited per queue; high-throughput mode raises it — check current published quotas
A failing message Retried, then DLQ; others are unaffected Blocks its whole group until it succeeds or reaches the DLQ

Step through the walkthrough below. The first tab follows messages through a standard queue — send, receive, delete — and then shows the three ways it goes wrong: a crash before delete, a worker slower than its visibility timeout, and a poison message ending in the DLQ. The second shows FIFO message groups keeping order and blocking on a failure. The third builds pub/sub out of SNS and queues, and shows why a late subscriber cannot replay.

Exactly-once effect on SQS

Start from what SQS guarantees. Standard queues are at-least-once with best-effort ordering: duplicates are not an edge case, they are a documented property. FIFO queues give ordering within a message group and deduplication on send. Neither gives you exactly-once processing, because the consumer can complete its side effect and then die before calling DeleteMessage. The redelivery will happen. Your handler must be idempotent regardless of queue type — that is the whole answer in one sentence, and it is the one interviewers are listening for.

What FIFO actually does.

The implementation that actually gives exactly-once effect. Pair the queue with a conditional write in DynamoDB:

PutItem(
  TableName: "idempotency",
  Item: { pk: <business key, e.g. payment_id>, state: "in_progress",
          expiry: now + 24h },
  ConditionExpression: "attribute_not_exists(pk)"
)

A ConditionalCheckFailedException means someone else has it: read the stored record, and if it is complete, return the saved response and delete the message. On success, do the work, update the item to complete with the response, then delete the message. Set a TTL well beyond your visibility timeout and retry window. Key on the business identity (payment ID), not the SQS MessageId, because a resend creates a new MessageId for the same payment. On Lambda, the Powertools idempotency utility implements this pattern rather than you hand-rolling it.

SQS: exactly-once effect with a conditional write
flowchart TD
  S["SendMessage<br/>MessageGroupId = account<br/>MessageDeduplicationId = explicit"] --> Q["FIFO queue<br/>5-minute dedup window on SEND"]
  Q --> RCV["Consumer receives<br/>visibility timeout starts"]
  RCV --> CW["DynamoDB PutItem<br/>ConditionExpression: attribute_not_exists(pk)<br/>pk = payment_id"]
  CW -->|"condition failed"| EX{"Stored state?"}
  EX -->|complete| DEL1["Return stored result, DeleteMessage"]
  EX -->|in_progress| WAIT["Let visibility expire, retry later"]
  CW -->|"write succeeded"| WORK["Do the work"]
  WORK --> UPD["Mark complete + store response"]
  UPD --> DEL2["DeleteMessage"]
  WORK -->|"crash before delete"| RCV

Visibility timeout is the other half. It must exceed your worst-case processing time, or SQS redelivers while you are still working and two workers race — the most common SQS bug in production. For variable work, extend the lease with ChangeMessageVisibility as you go rather than setting one huge timeout, which would delay recovery from genuine crashes. Set maxReceiveCount with a DLQ so a poison message parks after a bounded number of attempts.

Ordering behaviour worth knowing. In FIFO, a message that keeps failing blocks its own message group until it succeeds or hits the DLQ. That is parking-by-default, which is usually what payments want — but it means one bad payment stalls that account, so alert on group-level stalls.

Making the DynamoDB idempotency table CP

The pattern above leans on DynamoDB to say "this payment ID is already taken", so the table's consistency matters. DynamoDB is AP by default but can be configured as CP — CAP classifies operations, not products (R10).

Where Default (AP) Configured for consistency (CP)
Within one Region Eventually consistent reads may return stale data ConsistentRead=true returns the latest committed write, or an error when that cannot be guaranteed; conditional writes and TransactWriteItems make check-then-write atomic
Across Regions (global tables) Multi-Region eventual consistency: asynchronous replication, last writer wins, a few seconds of writes at risk if a Region fails Multi-Region strong consistency (MRSC, generally available since June 2025): each write is replicated synchronously to at least one other Region, strongly consistent reads return the latest version from any Region, and no acknowledged write is lost

MRSC has a precise price: exactly three Regions (three replicas, or two plus a non-serving witness), higher latency on every write and strongly consistent read, no transactions, and a ReplicatedWriteConflictException — to be retried — when two Regions write the same item at once, instead of a silent last-writer-wins.

Some parts never become strongly consistent. Global secondary indexes are always eventually consistent, and Streams consumers see changes after the table. Never decide "already processed" from a GSI read; use a conditional write or a strongly consistent read on the base table.

For the idempotency record the in-Region tools are what matter: a conditional PutItem on the business key is atomic regardless of read settings. MRSC earns its cost only when consumers in different Regions can process the same payment, and even then homing each account's writes to one Region usually stays the simpler design (R13).

Kafka versus SQS

Both are at-least-once, both need idempotent consumers, and both can keep per-key order. The differences that decide a design are about what happens after a message is read, and who runs the system.

Kafka SQS standard SQS FIFO
Model Replicated, append-only log Queue Queue with ordered groups
After a consumer acknowledges Message stays; only that group's offset moves Message is deleted Message is deleted
Several independent consumers Natural: one consumer group each SNS fan-out to one queue each SNS FIFO fan-out to one queue each
Ordering Per partition, by key Best effort Per message group
Scaling unit Partitions, sized up front; consumers beyond the partition count sit idle Effectively unbounded Message groups, effectively unbounded
Dedup on send Idempotent producer, per producer session None Dedup ID, 5-minute window
Retention and replay Time- or size-based, replayable by resetting offsets Until deleted, max 14 days, no replay Until deleted, max 14 days, no replay
Exactly-once Inside Kafka, with transactions Never; idempotent consumer Never; idempotent consumer
A failing message You build parking, retry topics and a DLQ Built-in redrive to a DLQ Built-in DLQ; blocks its group meanwhile
Operational cost You run or buy a cluster Fully managed Fully managed
Natural payments role Event backbone: PaymentSubmitted to many services, replayable Work queue for independent jobs Per-account ordered commands

A useful way to decide: is the message a fact others may care about (event), or a job for one worker (command)? Facts belong on a log, jobs belong on a queue. That is R0's distinction showing up as an infrastructure choice.

You need Use
Many independent consumers of the same events Kafka, or SNS fanning out to one SQS queue per consumer
Replay — rebuild a read model, re-run a day Kafka. SQS cannot do it; the messages are gone
Simple work queue, one worker pool, no ordering SQS standard
Work queue with per-key ordering, fully managed SQS FIFO with MessageGroupId
Very high throughput with strict per-key order Kafka
Lowest operational burden SQS — there is no cluster to run

The deciding difference for payments is replay. SQS is a work queue, not a log: once acknowledged, the message is gone. If you need to rebuild state or re-run a day's events, your source of truth has to be the ledger and the outbox, not the queue — say that explicitly if an interviewer proposes SQS for an event backbone.

What to say in the interview

"Kafka is a log, not a queue, so the key I choose is the ordering design — ordering and parallelism are the same dial turned in opposite directions. I run consumers with manual acknowledgement, dedupe and work in one transaction, and commit the offset last. Exactly-once semantics inside Kafka are real but stop at its edge, so anything leaving the building gets an idempotency key at the boundary instead. On SQS the equivalent is FIFO with a message group for ordering, plus a conditional write in DynamoDB for the effect — the queue never gives you exactly-once processing on its own."