Apache Kafka — Why Rebalance Storms Kill Consumer Groups
One slow consumer caused 45 min of Black Friday downtime.
- Kafka is a distributed, partitioned, append-only commit log — not a message queue. Data persists, consumers read by offset, messages are not deleted after consumption
- Core components: topics (named logs), partitions (parallelism units), offsets (sequence numbers), consumer groups (shared load), brokers (servers)
- Performance magic: sequential append writes + zero-copy sendfile — a single broker can saturate a 10Gbps NIC
- Partition count is your permanent max parallelism: you can never have more active consumers than partitions in a group
- Production trap: consumer rebalance storms when max.poll.interval.ms is too low — the group stops all consumption and oscillates
- Biggest mistake: acks=all without min.insync.replicas=2 — you think writes are durable but a single broker loss can still lose data
Imagine a massive newspaper printing plant that runs 24/7. Instead of delivering one paper to one house at a time, it prints millions of copies, sorts them into labeled bundles (topics), loads each bundle onto separate delivery trucks (partitions), and lets any number of paperboys (consumers) grab from their assigned truck without slowing anyone else down. If a truck breaks down, a spare steps in immediately — nobody misses their morning paper. Kafka is that printing plant, but for data.
Every modern distributed system eventually hits the same wall: services need to talk to each other faster than synchronous HTTP allows, and they need to do it reliably even when parts of the system go down. A payment service can't wait for analytics to finish before confirming a transaction. An inventory system can't drop an event just because the warehouse app is restarting. The moment you have more than two services exchanging real-time data, you need a message broker — and Kafka has become the industry's default answer.
Kafka solves a deceptively hard problem: durable, ordered, high-throughput, multi-consumer event streaming. Traditional message queues like RabbitMQ delete a message once it's consumed. Kafka keeps it, indexed by offset, for as long as you tell it to. This means you can replay events, onboard new consumers without re-producing data, and audit exactly what happened and when. That's not a queue — it's a distributed, append-only log. Understanding the difference separates engineers who use Kafka from engineers who understand Kafka.
By the end you'll be able to reason about partition assignment and why it determines your throughput ceiling, explain exactly how consumer group rebalancing works and when it silently kills your throughput, configure producers for durability versus latency trade-offs, and walk into any production incident knowing which knobs to turn first.
The Append-Only Log: Why Kafka's Core Data Structure Changes Everything
Most engineers learn Kafka by learning its API. That's backwards. To really understand it, start with the log — a data structure so simple it's almost boring, yet so powerful it underpins everything from databases (WAL in PostgreSQL) to distributed consensus (Raft).
A Kafka topic is not a queue. It's a named, partitioned, append-only log. When a producer sends a message, Kafka appends it to the end of the log and assigns it a monotonically increasing integer: the offset. Consumers don't pop messages off — they read forward from an offset they track themselves. This is the first mental model shift you need to make.
Because the log is append-only and offset-indexed, reads are sequential I/O — the fastest kind of disk operation. Kafka exploits this aggressively with zero-copy I/O (sendfile syscall), meaning data moves from disk to the network socket without ever touching user-space memory. A single Kafka broker can saturate a 10Gbps NIC handling millions of messages per second, not because Kafka is magic, but because it's built around hardware's natural strengths.
Each partition is an independent log stored as a set of segment files on disk. The active segment is open for writes. Older segments are immutable and eligible for deletion or compaction based on your retention policy. Understanding this file structure is critical when you're debugging disk space alerts or tuning log compaction.
Partitions, Consumer Groups, and the Parallelism Contract You Must Honour
Here's the rule that most engineers learn the hard way: within a consumer group, each partition is assigned to exactly one consumer at any moment. One consumer can read from multiple partitions, but one partition cannot be read by multiple consumers in the same group simultaneously. This is Kafka's ordering guarantee — and it's also your parallelism ceiling.
If you have a topic with 6 partitions and spin up 8 consumers in the same group, 2 of those consumers will sit completely idle. You've paid for 8 processes and get the throughput of 6. Conversely, if you have 6 partitions and 2 consumers, each consumer reads from 3 partitions — perfectly fine, just not maximally parallel.
Consumer group rebalancing is where production systems silently degrade. A rebalance happens when a consumer joins, leaves, or is considered dead by the group coordinator broker. During a rebalance, all consumption stops — this is the stop-the-world event of the Kafka world. A slow consumer that exceeds max.poll.interval.ms (default 5 minutes) triggers a rebalance even if the consumer process is healthy. This is one of the most misdiagnosed issues in Kafka production systems.
Kafka 2.4+ introduced Incremental Cooperative Rebalancing (the StickyAssignor strategy), which lets consumers release only the partitions they need to give up rather than all of them. This eliminates the full stop-the-world pause for most real rebalances. If you're on an older assignor, you're leaving throughput on the table.
Producer Durability vs Latency: The acks, retries, and idempotence Triangle
Every Kafka producer configuration is really a negotiation between three forces: how fast you want to send, how sure you want to be the message arrived, and how much you'll tolerate duplicates. Getting this wrong is expensive in production — either you lose data silently or you process orders twice.
The acks setting controls acknowledgement behaviour. acks=0 means fire and forget — fastest, no guarantees. acks=1 means the partition leader acknowledges — fast, but if the leader dies before replication, the message is lost. acks=all (or -1) means every in-sync replica (ISR) must acknowledge — durable, but only as strong as your min.insync.replicas setting.
Here's the trap: acks=all with min.insync.replicas=1 is effectively the same as acks=1, because only one replica needs to be in sync. The correct production configuration is acks=all combined with min.insync.replicas=2 on a cluster with replication factor 3. This tolerates one broker failure with zero data loss.
Idempotent producers (enable.idempotence=true), available since Kafka 0.11, solve duplicate delivery during retries. The broker assigns each producer a PID (Producer ID) and tracks a sequence number per partition. If a retry arrives with the same sequence number, the broker deduplicates it. This gives you exactly-once semantics at the producer level with zero application-side logic. Always enable this in production — the overhead is negligible.
send() blocksReplication, ISR, and What Actually Happens When a Broker Dies
Replication in Kafka is leader-follower. Each partition has one leader and N-1 followers spread across brokers. Producers and consumers always talk to the leader — followers exist purely for fault tolerance. The leader tracks which followers are caught up: if a follower hasn't fetched within replica.lag.time.max.ms (default 30 seconds), it's removed from the ISR. When the leader fails, the controller broker (elected via Zookeeper or KRaft) promotes one of the ISR members to leader.
This is where min.insync.replicas (often called MISR) becomes critical. With replication factor 3 and min.insync.replicas=2, you can lose one broker and still accept writes. Lose two brokers and the topic partition becomes unavailable for writes — producers get a NotEnoughReplicasException. This is intentional: Kafka chooses consistency over availability in this scenario, which is the correct trade-off for financial data.
Unclean leader election (unclean.leader.election.enable) is the escape hatch. If all ISR members are dead and this setting is true, Kafka will elect an out-of-sync replica as leader — potentially losing committed messages. This defaults to false in Kafka 0.11+ for exactly this reason. Only enable it for topics where availability beats data integrity.
Kafka 2.8+ introduced KRaft mode, replacing Zookeeper with a Raft-based metadata quorum built into Kafka itself. This eliminates the operational complexity of running a separate Zookeeper ensemble and dramatically speeds up controller failover — from tens of seconds to under a second.
The Rebalance Storm That Killed Black Friday Sales
max.poll.interval.ms of 5 minutes was generous — their processing per batch averaged 30 seconds. They didn't account for long-tail latency: occasional batches with large messages or complex enrichment took 4.5 minutes. The consumer sent heartbeats, so the broker kept the session alive, but the group coordinator saw no poll() calls for 4.5 minutes and initiated a rebalance, thinking the consumer was dead.max.poll.interval.ms by 30 seconds due to an external API call timing out. The group coordinator removed it from the group and triggered a full rebalance (stop-the-world). During the rebalance, the consumer finished processing and called poll() again — but the coordinator had already removed it. The consumer re-joined, triggering another rebalance. This cycle repeated endlessly. The default partition.assignment.strategy (RangeAssignor) forced a full revocation of all partitions, not just the affected ones, making the storm worse.max.poll.interval.ms to 10 minutes to cover the worst-case processing time. Switched to CooperativeStickyAssignor (Kafka 2.4+), which only revokes partitions that need to move, not all of them. Moved the slow external API call from the poll thread to a separate thread pool, keeping the poll thread fast. Added a circuit breaker that fails fast on API timeouts instead of waiting the full timeout. After the fix, rebalances dropped from 47 per hour to 0.max.poll.interval.msmust cover your 99.9th percentile processing time, not the average. Long-tail latency kills consumer groups.- Never place blocking or slow operations on the Kafka poll thread. Offload to a processing thread pool and keep
poll()under 100ms. - Upgrade to CooperativeStickyAssignor. The stop-the-world rebalance of RangeAssignor is a production liability for any group with >2 consumers.
- Monitor rebalance metrics:
kafka.consumer:type=consumer-coordinator-metricslooking forrebalances.totalandrebalance.time.total. A rising rebalance count is always a red alert.
max.poll.interval.ms vs actual per-batch processing time. Run kafka-consumer-groups.sh --describe --group <group> and look for any consumer with high time since last poll. Increase max.poll.interval.ms to cover worst-case processing. Switch from RangeAssignor to CooperativeStickyAssignor by setting partition.assignment.strategy=org.apache.kafka.clients.consumer.CooperativeStickyAssignor in consumer config.NotEnoughReplicasException — writes failing intermittentlykafka-topics.sh --describe --topic <topic>. If Isr list size < min.insync.replicas, writes will fail. Check broker logs for follower fetch lag. Most common cause: a follower broker is falling behind due to disk I/O saturation or network latency. Increase replica.lag.time.max.ms temporarily, but address the underlying performance issue.enable.auto.commit — if true, change to false. Implement manual commitSync() ONLY after the entire batch is successfully processed and the downstream transaction is committed. Make downstream operations idempotent using a message-ID deduplication table with TTL.send() blocks for secondsbuffer.memory vs actual usage. If the producer's buffer fills because the broker is slow to acknowledge, send() blocks. Monitor record-queue-time metric. Increase buffer.memory from default 32MB to 64MB or 128MB. Check broker request.handler.threads — if saturated, broker is the bottleneck, not producer.max.poll.interval.ms=600000 (10 min). Set partition.assignment.strategy=CooperativeStickyAssignor. Move slow processing off the poll thread to a separate executor pool.Key takeaways
Common mistakes to avoid
5 patternsUsing low-cardinality keys (country, region) as partition keys
partitionKey = s"${randomSalt}_${naturalKey}". For composite keys, ensure the first component has high cardinality. Test distribution with kafka-run-class GetOffsetShell and compare partition totals. If hot partitions are unavoidable, increase partition count to spread the hot keys across more partitions.Using auto-commit with slow business logic
enable.auto.commit=false. Call consumer.commitSync() explicitly only after your DB transaction commits. Make downstream operations idempotent using a message-ID deduplication table with TTL. For high throughput, commit offsets periodically after every N messages, but accept at-least-once semantics.Setting partition count higher than consumer count without planning for future scale
max(target_throughput_MB / single_consumer_throughput_MB, target_consumer_count * 3) rounded up to a power of 2. You can increase partitions later but you cannot decrease them without creating a new topic. Over-provision by at least 3x.Ignoring max.poll.interval.ms when processing is slow
kafka-consumer-groups.sh shows STATE=PreparingRebalance repeatedly. Lag oscillates rather than growing linearly. Consumption stops completely during rebalances.max.poll.interval.ms to exceed your worst-case processing time per batch (e.g., 10 minutes). Better yet, decouple Kafka polling from business logic using an internal queue and a separate processing thread pool, keeping the Kafka poll thread under 100ms.Setting acks=all but forgetting min.insync.replicas=2
min.insync.replicas=2 at the topic level when using acks=all. This requires replication.factor=3 (so 3 replicas total, 2 in ISR required). Test by killing a leader broker while producing — writes should continue without errors and no data loss.Interview Questions on This Topic
Explain exactly what happens — at the broker and consumer level — when a Kafka consumer exceeds max.poll.interval.ms. What does the broker do, what does the consumer see, and how does this affect other consumers in the same group?
poll(). When max.poll.interval.ms passes without a poll call, the coordinator marks the consumer as dead and initiates a rebalance, removing that consumer from the group. The consumer meanwhile finishes processing its batch and calls poll() again. It discovers that its assignment has been revoked (via a WakeupException on older clients, or an empty partition set on newer ones). It then re-joins the group, triggering ANOTHER rebalance. This creates a continuous loop: the slow consumer is repeatedly removed, rejoins, and removed again. During each rebalance, all consumers in the group stop processing entirely. On the default RangeAssignor, all partitions are revoked from all consumers, causing a full stop-the-world pause. On CooperativeStickyAssignor, only the partitions belonging to the slow consumer are revoked, leaving others processing. The solution is to increase max.poll.interval.ms to cover worst-case processing time, or move processing off the poll thread entirely.Frequently Asked Questions
That's NoSQL. Mark it forged?
5 min read · try the examples if you haven't