Consumer Group Rebalancing: Protocols and Tuning
In a Kafka-based pipeline, the stability of your consumer groups directly determines the reliability of stream processing. A rebalance is the mechanism that gives consumer groups their elasticity and fault tolerance, but it is also the most common source of unpredictable latency and processing pauses. The difference between a group that quietly absorbs membership changes and one that stalls for seconds during every deploy comes down to which rebalance protocol you run and how you tune it.
This article dissects how Kafka’s rebalance protocols work, what changed with incremental cooperative rebalancing (KIP-429) and the next-generation server-driven protocol (KIP-848), and how to tune heartbeats, timeouts, offset commits, and membership to keep processing predictable. These protocols are tightly coupled to the delivery guarantees discussed in Producers, Consumers & Delivery Semantics.
When a consumer leaves a group, processing for the partitions it owned halts until ownership is reassigned. Under the legacy protocol, that halt extends across the entire group until a new assignment is distributed — lag spikes, alerts fire, and applications can miss processing windows. The operational goal is to shrink that “stop-the-world” window toward zero while ensuring partition ownership transitions cleanly, without data loss or duplication.
The Anatomy of a Kafka Rebalance Event
A rebalance is a multi-phase protocol orchestrated by the group coordinator, a broker responsible for managing the state of a consumer group. It is triggered by a change in group membership: a consumer joining, an existing consumer shutting down gracefully, or the coordinator detecting a failure via a missed heartbeat. Subscription metadata changes (for example, a topic matching a subscription pattern gaining partitions) can also trigger one.
In the classic protocol, the trigger is a JoinGroup request. When a consumer starts or its session times out, it sends this request to the coordinator. The coordinator holds the request open until either all known members have re-joined or group.initial.rebalance.delay.ms (broker config, default 3000 ms) expires. This delay batches the initial joins so a freshly started group does not rebalance once per consumer. The coordinator then selects a group leader — typically the first member to join — and returns the member list and the candidate assignment strategies to it. The leader runs the configured partition assignor and returns the assignment to the coordinator in a SyncGroup request. The coordinator distributes assignments to all members, and the group moves to the Stable state.
During this exchange no consumer in the group is fetching. Under the eager behavior, every member revokes all of its partitions when the rebalance starts, producing a complete processing halt. The cooperative behavior, introduced in KIP-429, lets consumers retain partitions that stay assigned to them across the rebalance and revoke only the partitions that are actually moving.
The Group Coordinator and State Management
The coordinator is not a dedicated component — it is whichever broker owns the __consumer_offsets partition for that group. The mapping is deterministic: hash(group.id) % <number of __consumer_offsets partitions> selects the partition, and that partition’s leader is the coordinator. You can inspect a group, including its coordinator and per-member assignment, with kafka-consumer-groups:
kafka-consumer-groups --bootstrap-server kafka-broker-1:9092 \
--describe --group order-processing-service
The default --describe output lists each member’s CONSUMER-ID, HOST, CLIENT-ID, assigned partitions, current offset, log-end offset, and lag. To see the group’s lifecycle state and coordinator, add --state. A group transitions from Stable to PreparingRebalance when a rebalance begins, then to CompletingRebalance, then back to Stable. If a member is stuck and cannot rejoin, the group can sit in PreparingRebalance and stall all processing.
The coordinator tracks each consumer with heartbeats. heartbeat.interval.ms (default 3000 ms) sets how often heartbeats are sent; session.timeout.ms (default 45000 ms) sets how long the coordinator waits without a heartbeat before declaring the member dead and triggering a rebalance.
Eager vs. Cooperative Rebalancing
Classic consumer groups support two rebalance behaviors, selected by the partition assignor you configure: eager (revoke everything) and incremental cooperative (revoke only what moves). The choice has direct consequences for processing latency, throughput stability, and the complexity of your consumer logic.
The Eager Protocol: Stop-the-World Semantics
Under eager rebalancing, when a rebalance is triggered every consumer receives onPartitionsRevoked for all of its partitions, commits offsets, relinquishes ownership, and only then sends a fresh JoinGroup. From the moment the first consumer revokes until the last consumer receives its new assignment, no partition in the subscription is being consumed. With many members or many partitions, this pause can run from hundreds of milliseconds to several seconds.
Eager rebalancing is especially costly during rolling restarts. Each consumer that stops and restarts triggers a full group rebalance, so restarting a 20-instance group produces a long series of stop-the-world rebalances in succession.
The Cooperative Protocol: Incremental Rebalancing
Incremental cooperative rebalancing was introduced in Kafka 2.4 (KIP-429) and is implemented by the CooperativeStickyAssignor. Instead of revoking everything, consumers revoke only the partitions being moved to another member and keep processing the partitions they retain.
It achieves this with a two-pass rebalance. In the first pass the leader computes the target assignment and tells each member which partitions to give up; those members revoke exactly those partitions and rejoin. In the second pass the leader hands the freed partitions to their new owners. Throughout, members continue consuming the partitions they kept, so the interruption is confined to the partitions that actually changed hands.
To run cooperative rebalancing on a classic consumer, set the assignor:
Properties props = new Properties();
props.put("bootstrap.servers", "kafka-broker-1:9092,kafka-broker-2:9092");
props.put("group.id", "order-processing-service");
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("partition.assignment.strategy",
"org.apache.kafka.clients.consumer.CooperativeStickyAssignor");
A common misconception is that cooperative rebalancing became the default in Kafka 3.0. What actually changed (KIP-726) is that the default value of partition.assignment.strategy became the ordered list [RangeAssignor, CooperativeStickyAssignor]. Because RangeAssignor is first, an out-of-the-box classic consumer still rebalances eagerly; the CooperativeStickyAssignor is present only so you can migrate to cooperative mode with a single rolling bounce (drop RangeAssignor from the list) without a brief period where the two assignors disagree on protocol. You must explicitly select the cooperative assignor to get incremental behavior.
For a step-by-step migration, see How to Configure Cooperative Rebalancing for Zero-Downtime Deployments.
The payoff is concrete: under eager rebalancing a rolling restart pauses the whole group on every bounce, while under cooperative rebalancing only the partitions being reassigned pause, typically for tens to a few hundred milliseconds each. The cumulative stall during a rolling deploy drops from seconds-per-bounce to a small fraction of that.
The Next-Generation Protocol (KIP-848)
Kafka 4.0 ships the new consumer group protocol (KIP-848) as generally available. It moves assignment computation off the client leader and onto the broker, eliminating the global synchronization barrier — there is no leader-computed SyncGroup round trip, and members reconcile their assignments incrementally and asynchronously. This removes the failure mode where a single slow leader stalls the whole group.
You opt in per consumer by setting group.protocol (default classic; case-insensitive, valid values classic and consumer):
props.put("group.protocol", "consumer");
Under the new protocol, assignment is server-side, so partition.assignment.strategy is no longer usable on the consumer. The broker chooses an assignor (the uniform and range server assignors are enabled by default); a consumer may request one with group.remote.assignor. The uniform assignor is sticky and incremental, giving the same “keep what you own” behavior as CooperativeStickyAssignor without any client-side assignor. Custom client-side assignors are not supported under the new protocol.
This is the recommended target for new deployments on Kafka 4.0+. Migration is rolling and online: a classic group converts as members switch to group.protocol=consumer.
Partition Assignment Strategies (Classic Protocol)
In classic groups the assignor is the algorithm that maps partitions to consumers. Kafka ships four built-in assignors, all in org.apache.kafka.clients.consumer, configured via partition.assignment.strategy and executed by the group leader on each rebalance.
Range Assignor
RangeAssignor works per topic: for each subscribed topic it sorts partitions and consumers and assigns contiguous ranges. With 6 partitions and 3 consumers, consumer 1 gets partitions 0–1, consumer 2 gets 2–3, consumer 3 gets 4–5. It is simple but imbalances across multiple topics, because the lexicographically-first consumers tend to accumulate the lower-numbered partitions of every topic. It is eager and does not preserve prior assignments, so every rebalance reshuffles.
RoundRobin Assignor
RoundRobinAssignor lays all subscribed partitions out in one pool and distributes them round-robin across all consumers, giving more even distribution than Range for multi-topic subscriptions. Like Range it is eager and does not preserve assignments, so a single member leaving can move partitions across many remaining consumers.
Sticky Assignor
StickyAssignor keeps the balance of RoundRobin while minimizing movement: it aims for an even distribution and for retaining each consumer’s existing partitions across rebalances. When one consumer leaves, only its partitions are redistributed. StickyAssignor still uses the eager protocol (all partitions are revoked and re-assigned each rebalance), so although the final assignment moves few partitions, processing still pauses during the rebalance.
CooperativeSticky Assignor
CooperativeStickyAssignor applies the same sticky balancing logic but on the incremental cooperative protocol, so retained partitions keep being processed during the rebalance. On the classic protocol this is the recommended assignor for new applications; on Kafka 4.0+ prefer the new group.protocol=consumer path, where the server-side uniform assignor provides the equivalent behavior.
To watch assignor behavior, describe the group before and after stopping a consumer:
# Before stopping a consumer
kafka-consumer-groups --bootstrap-server kafka-broker-1:9092 \
--describe --group test-group
# After stopping one consumer, describe again to see the reassignment
kafka-consumer-groups --bootstrap-server kafka-broker-1:9092 \
--describe --group test-group
With a sticky/cooperative assignor only the departed consumer’s partitions are redistributed; the rest stay put.
Tuning Heartbeat and Session Timeouts
Heartbeat and session timeouts are the primary levers controlling how sensitive group membership is. Misconfiguring them is one of the most common causes of spurious rebalances.
The Heartbeat Mechanism
In the classic protocol each consumer sends heartbeats from a dedicated background thread, separate from the poll() loop. heartbeat.interval.ms controls the cadence; session.timeout.ms is the maximum time the coordinator waits without a heartbeat before declaring the member dead and rebalancing.
session.timeout.ms must be comfortably larger than heartbeat.interval.ms so that a few missed heartbeats — from a transient network blip or a GC pause — don’t evict a healthy consumer. Keep the heartbeat interval no higher than one-third of the session timeout. The defaults (3000 ms heartbeat, 45000 ms session) already satisfy this; session.timeout.ms must also fall within the broker-enforced bounds group.min.session.timeout.ms (default 6000 ms) and group.max.session.timeout.ms (default 1800000 ms).
Max Poll Interval and Liveness
max.poll.interval.ms (default 300000 ms, 5 minutes) caps the time between successive poll() calls. Because heartbeats run on a background thread, the consumer keeps its session alive even while processing a batch — but if processing between polls exceeds max.poll.interval.ms, the consumer proactively leaves the group and a rebalance is triggered. This decouples “is the process alive” (heartbeats / session.timeout.ms) from “is it making progress” (max.poll.interval.ms).
The classic failure loop: a consumer takes too long on a batch, exceeds max.poll.interval.ms, leaves the group, then rejoins and is handed the same uncommitted batch again — repeating indefinitely. Fix it by reducing batch size with max.poll.records (default 500) or raising max.poll.interval.ms to cover worst-case processing time.
A production-oriented configuration that balances responsiveness with stability:
Properties props = new Properties();
props.put("bootstrap.servers", "kafka-broker-1:9092,kafka-broker-2:9092");
props.put("group.id", "critical-processing-service");
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
// Cooperative rebalancing (classic protocol)
props.put("partition.assignment.strategy",
"org.apache.kafka.clients.consumer.CooperativeStickyAssignor");
// Heartbeat / session
props.put("heartbeat.interval.ms", 3000); // 3 seconds
props.put("session.timeout.ms", 30000); // 30 seconds
// Progress timeout and batch size
props.put("max.poll.interval.ms", 600000); // 10 minutes
props.put("max.poll.records", 500);
// Manual offset management
props.put("enable.auto.commit", false);
Tuning for Different Workload Profiles
Optimal values follow your workload. For low-latency, CPU-bound processing where each record takes microseconds, tighter timeouts (for example session.timeout.ms=10000) detect failures quickly. For I/O-bound consumers making external calls or batch database writes, you need headroom — a larger session.timeout.ms and especially a generous max.poll.interval.ms to cover slow batches.
The trade-off is failure-detection speed: the longer your timeouts, the longer a genuinely dead consumer’s partitions sit unprocessed before the group reacts and reassigns them.
Managing Offset Commits During Rebalances
Offset management and rebalancing are inseparable. When a consumer revokes a partition it must commit its current offset so the next owner resumes cleanly, without duplication or loss.
The Perils of Automatic Commits
With enable.auto.commit=true, offsets are committed on a timer (auto.commit.interval.ms, default 5000 ms) during poll(). That is convenient but blunt around rebalances: records processed since the last auto-commit may be reprocessed by the new owner (duplicates), and conversely auto-commit can advance the offset past records that downstream work has not yet finished, so a rebalance skips them (loss). For any workload where guarantees matter, disable auto-commit and commit manually. See Offset Management Strategies for Reliable Processing.
Synchronous vs. Asynchronous Commits
commitSync() blocks until the coordinator acknowledges, guaranteeing the offset is persisted (and retrying retriable errors) at the cost of loop latency. commitAsync() is non-blocking and faster but offers no built-in retry; if it fails or is in flight when a rebalance starts, that commit may be lost.
The robust pattern is to use commitAsync() in the hot loop for throughput and a commitSync() in the revocation callback so the final offset is durably committed before the partition is handed off:
import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.TopicPartition;
import java.time.Duration;
import java.util.*;
public class ReliableConsumer {
private final KafkaConsumer<String, String> consumer;
private final Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>();
public ReliableConsumer(Properties props, String topic) {
this.consumer = new KafkaConsumer<>(props);
this.consumer.subscribe(Collections.singletonList(topic), new ConsumerRebalanceListener() {
@Override
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
// Synchronous commit on revocation to avoid reprocessing/loss at handoff
System.out.println("Revoking partitions: " + partitions);
consumer.commitSync(currentOffsets);
}
@Override
public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
System.out.println("Assigned partitions: " + partitions);
}
});
}
public void run() {
try {
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
for (ConsumerRecord<String, String> record : records) {
processRecord(record);
currentOffsets.put(
new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset() + 1)
);
}
consumer.commitAsync(currentOffsets, (offsets, exception) -> {
if (exception != null) {
System.err.println("Async commit failed: " + exception.getMessage());
}
});
}
} finally {
consumer.close(); // commits final offsets and leaves the group cleanly
}
}
private void processRecord(ConsumerRecord<String, String> record) {
// Your processing logic here
}
}
Note that under the cooperative protocol the revocation callback fires only for the partitions actually being moved, not the entire assignment, so the commit-on-revoke path touches far fewer partitions than under eager rebalancing. If you need symmetric handling, onPartitionsLost covers the case where partitions are taken away without a clean revocation (for example after the consumer was already evicted).
Exactly-Once Semantics and Rebalances
For exactly-once processing, offset commits and output must be atomic. Kafka’s transactional API commits consumed offsets to __consumer_offsets inside the same transaction as the produced output (via producer.sendOffsetsToTransaction(...)), so a rebalance can never leave output written but the offset uncommitted (or vice versa). On revocation the transactional pipeline aborts or commits the in-flight transaction so the boundary stays consistent. See Idempotent Producers and Exactly-Once Semantics.
Diagnosing and Resolving Rebalance Storms
A rebalance storm is a pathological loop in which a group never stays Stable long enough to make progress — one of the most disruptive failure modes in Kafka operations.
Common Causes
- Slow rejoin / exceeded
max.poll.interval.ms. A member stuck in long processing misses the rejoin window; the rebalance restarts when it finally returns, and the cycle repeats. - Crash-on-startup loops. A consumer that joins and then dies on a config error or init exception triggers a rebalance on each join and each departure, oscillating rapidly.
- Network blips and GC pauses. Intermittent connectivity to the coordinator causes repeated session timeouts and rejoins.
Diagnostic Commands and Metrics
Start with the group’s lifecycle state:
kafka-consumer-groups --bootstrap-server kafka-broker-1:9092 \
--describe --state --group problematic-group
If the state oscillates between PreparingRebalance and CompletingRebalance and never settles on Stable, you are in a storm. Then look at the members:
kafka-consumer-groups --bootstrap-server kafka-broker-1:9092 \
--describe --members --verbose --group problematic-group
--members --verbose adds the per-member partition list; watch for members that appear and disappear or hold an unexpected partition count. On the consumer JVMs, the consumer-coordinator-metrics MBean exposes last-rebalance-seconds-ago (a value that keeps resetting to near zero confirms continuous rebalancing) and failed-rebalance-rate-per-hour.
Resolution
Match the fix to the trigger: for slow consumers raise max.poll.interval.ms or lower max.poll.records; for crash loops fix the startup bug and add error handling; for network issues investigate the path to the broker and consider a larger session.timeout.ms to tolerate blips. Migrating to CooperativeStickyAssignor (or group.protocol=consumer on 4.0+) also shrinks the blast radius of each rebalance and reduces the chance that one slow member stalls the whole group.
As a last resort, break the cycle by stopping all consumers and resetting offsets:
# Stop all consumers first, then reset offsets
kafka-consumer-groups --bootstrap-server kafka-broker-1:9092 \
--reset-offsets --to-earliest \
--topic critical-topic --group problematic-group --execute
Offset reset only works when the group is empty (no active members), and --to-earliest reprocesses everything from the earliest retained offset. Use it only when other measures fail and reprocessing is acceptable.
Monitoring and Alerting for Rebalance Health
Kafka exposes rebalance health through JMX on both brokers and consumers; scrape it with the JMX exporter into Prometheus and visualize in Grafana. Exact exported metric names depend on your exporter’s naming rules, so confirm them against your scrape config — the names below are the JMX MBean/attribute names.
Key Metrics
Consumer side — kafka.consumer:type=consumer-coordinator-metrics,client-id=*:
rebalance-latency-avg,rebalance-latency-max— average/maximum time a rebalance takes.rebalance-rate-per-hour,rebalance-total— how often this consumer participates in rebalances.failed-rebalance-rate-per-hour,failed-rebalance-total— failed rebalances; sustained non-zero values signal instability.last-rebalance-seconds-ago— time since the last rebalance; a value that keeps resetting indicates a storm.
Consumer side — kafka.consumer:type=consumer-fetch-manager-metrics,client-id=*:
records-lag-max— maximum lag across assigned partitions; lag spikes often coincide with rebalances.
Broker side — group coordinator. Metric names differ by version. On the new group coordinator (Kafka 3.7+ / 4.0), use kafka.server:type=group-coordinator-metrics with consumer-group-rebalance-rate / consumer-group-rebalance-count for new-protocol groups, group-completed-rebalance-rate / group-completed-rebalance-count for classic groups, and group-count (tagged by protocol) plus consumer-group-count (tagged by state) for group inventory. Confirm the exact names exposed by your broker version rather than hard-coding them.
A Prometheus Alert
Because the exported names depend on the JMX exporter rules, treat the metric in the expression as a placeholder that you align with your exporter output (the broker-side classic-group rebalance metric typically exports from kafka.server:type=group-coordinator-metrics,name=group-completed-rebalance-rate):
groups:
- name: kafka_consumer_alerts
rules:
- alert: HighConsumerGroupRebalanceRate
# Adjust the metric name to match your JMX-exporter output.
expr: rate(kafka_consumer_coordinator_metrics_rebalance_total[5m]) > 0.1
for: 10m
labels:
severity: warning
annotations:
summary: "Consumer group is rebalancing frequently"
description: "Rebalance rate is {{ $value }}/s over 5m, indicating an unstable group."
- alert: ConsumerGroupFailedRebalances
expr: rate(kafka_consumer_coordinator_metrics_failed_rebalance_total[5m]) > 0
for: 5m
labels:
severity: critical
annotations:
summary: "Consumer group has failing rebalances"
description: "Failed rebalances detected over the last 5 minutes."
Grafana Dashboard Panels
A useful consumer-group health dashboard includes:
- Rebalance rate by group — time series of rebalances per group.
- Consumer lag —
records-lag-maxper partition/group, annotated with rebalance events to correlate lag spikes with instability. - Group state timeline — transitions through
Stable,PreparingRebalance,CompletingRebalance. - Coordinator load — groups per coordinator broker, to spot hotspots.
The Apache Kafka monitoring documentation is the authoritative reference for metric definitions.
Advanced Tuning for Large-Scale Deployments
Hundreds of consumers, thousands of partitions, and dozens of topics introduce concerns beyond the basics.
Coordinator Load Distribution
Each group is managed by a single coordinator broker, so a few very large groups can concentrate load on a few brokers and slow rebalance processing. Group-to-coordinator mapping is hash(group.id) % <__consumer_offsets partitions>, so a diverse set of group IDs naturally spreads across coordinators; a handful of similarly named groups can collide on the same partition. The __consumer_offsets topic defaults to 50 partitions (offsets.topic.num.partitions), which is fixed at first creation and effectively immutable afterward, so size it deliberately if you expect very large numbers of groups.
Startup Batching
group.initial.rebalance.delay.ms (broker config, default 3000 ms) delays the first rebalance of a newly created group so members that start within the window join together instead of each triggering its own rebalance. For large groups whose members start staggered, raising this (for example to 10000–15000 ms) avoids a flurry of partial rebalances at startup. It applies only to the group’s initial formation, not to later rebalances.
Static Group Membership
Static membership (KIP-345) lets a consumer keep its identity across restarts. Setting a unique group.instance.id per instance makes the consumer a static member: when it leaves and rejoins within session.timeout.ms, the coordinator recognizes it and skips the rebalance, returning the same partitions. This is valuable for stateful processing where partition ownership is tied to a local state store, since it avoids re-warming state on every bounce.
Properties props = new Properties();
props.put("group.id", "stateful-processing-service");
props.put("group.instance.id", "instance-1"); // unique per instance
props.put("session.timeout.ms", 30000);
// A restart that completes within session.timeout.ms triggers no rebalance.
Each instance must have a distinct group.instance.id (a duplicate ID is fenced — only one instance with a given ID may be in the group at a time), and your rolling-restart process must bring each instance back inside session.timeout.ms. Size session.timeout.ms to cover your real restart time, since the partitions of a down static member are not processed until it returns or the session expires.
Large Partition Counts and the Classic SyncGroup Bottleneck
In the classic protocol the leader embeds the full assignment (all partitions and owners) in the SyncGroup response, and that payload grows with the partition count. Groups subscribing to tens of thousands of partitions can hit slow or failing SyncGroup phases as this metadata balloons. The new protocol (KIP-848) removes this bottleneck by computing assignments on the broker and reconciling them incrementally, with no leader-built assignment blob — which is one of the strongest reasons to adopt group.protocol=consumer for very large groups on Kafka 4.0+. On the classic protocol, reduce partitions per topic or split work across groups; Kafka scales best with partitions per topic in the hundreds to low thousands. KIP-429 (Kafka Consumer Incremental Rebalance Protocol) and KIP-848 (The Next Generation of the Consumer Rebalance Protocol) cover the scalability design in depth.
Conclusion
Rebalancing is the control point that decides the stability, predictability, and correctness of a Kafka stream-processing pipeline. Moving from eager, stop-the-world rebalancing to the incremental cooperative model — and, on Kafka 4.0+, to the server-driven KIP-848 protocol — is one of the highest-leverage changes available to a Kafka operator: it confines each rebalance to the partitions that actually move and removes the single-leader bottleneck that turns a slow consumer into a group-wide stall.
Pair that with deliberate timeout tuning (heartbeat versus session versus max.poll.interval.ms), commit-on-revoke offset handling, static membership for stateful apps, and monitoring built on real consumer-coordinator-metrics and group-coordinator metrics, and the unpredictable latency spikes that plague poorly tuned groups largely disappear. As always with distributed systems: understand the protocol, measure it in your own environment, and tune to observed data rather than assumptions.