Kafka - Consumer Group Rebalancing & Strategies

A consumer group rebalance occurs whenever the set of consumers in a group changes: a new consumer joins, an existing one leaves or crashes, or topic partitions are added. During a rebalance, partitions are reassigned across the group. The default eager protocol stops all consumers during this process, but cooperative (incremental) rebalancing minimizes disruption by only revoking partitions that need to move.

Share

Contents

What is a Rebalance?

A rebalance is the mechanism Kafka uses to redistribute partitions across the consumers in a consumer group. Every consumer group has a group coordinator: a broker that manages group membership and triggers rebalances when the group state changes.

Rebalances are triggered when:

  • A new consumer joins the group (e.g., a new instance starts up).
  • An existing consumer leaves the group (graceful shutdown via consumer.close()).
  • A consumer is considered dead: it fails to send heartbeats within session.timeout.ms or fails to call poll() within max.poll.interval.ms.
  • New partitions are added to a subscribed topic.
  • The subscription pattern matches a newly created topic (when using regex subscription).

During a rebalance the group coordinator collects partition assignments from all active consumers, then uses a configurable partition assignor to compute the new mapping. The result is sent back to each consumer so it knows which partitions to read from.

The group coordinator is always the broker that hosts the partition of the internal __consumer_offsets topic corresponding to the consumer group. Kafka selects this partition using hash(group.id) % offsets.topic.num.partitions.

Eager Rebalancing (Stop-the-World)

The eager rebalance protocol is the original Kafka approach. When a rebalance begins, every consumer in the group revokes all of its partitions before any partitions are reassigned. This causes a complete pause of consumption across the entire group.

The two built-in eager assignors are:

  • RangeAssignor (default before Kafka 3.0): Assigns partitions on a per-topic basis. Partitions of each topic are divided among consumers in lexicographic order by consumer id. This can lead to uneven distribution when the partition count is not evenly divisible.
  • RoundRobinAssignor: Assigns all partitions across all subscribed topics in a round-robin fashion. Produces a more balanced assignment than RangeAssignor when consumers subscribe to the same set of topics.
Java
// Configure RoundRobinAssignor (eager protocol)
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "order-processing-group");
props.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
          "org.apache.kafka.clients.consumer.RoundRobinAssignor");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
          "org.apache.kafka.common.serialization.StringDeserializer");
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
          "org.apache.kafka.common.serialization.StringDeserializer");

KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(List.of("orders", "payments"));
With eager rebalancing, even if only one partition needs to move, all consumers stop consuming, revoke every partition, and wait for the new assignment. In large consumer groups this stop-the-world pause can last several seconds and significantly affect throughput.

Cooperative (Incremental) Rebalancing

Cooperative rebalancing (introduced in Kafka 2.4) eliminates the stop-the-world pause by only revoking the partitions that actually need to move to a different consumer. Consumers that keep their existing partitions continue processing without interruption.

The cooperative protocol uses a two-phase approach:

  • Phase 1: The assignor computes the ideal assignment and identifies which partitions need to migrate. Only those partitions are revoked from their current owners. All other partitions continue being consumed normally.
  • Phase 2: A second rebalance is triggered automatically. The revoked partitions are now unowned and get assigned to their new consumers.

The built-in cooperative assignor is CooperativeStickyAssignor, which combines the incremental protocol with sticky assignment: it tries to preserve existing partition-to-consumer mappings as much as possible.

Java
// Configure CooperativeStickyAssignor (incremental protocol)
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "order-processing-group");
props.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
          "org.apache.kafka.clients.consumer.CooperativeStickyAssignor");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
          "org.apache.kafka.common.serialization.StringDeserializer");
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
          "org.apache.kafka.common.serialization.StringDeserializer");

KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(List.of("orders", "payments"));
Starting with Kafka 3.1, CooperativeStickyAssignor is included in the default list of assignors alongside RangeAssignor. From Kafka 4.0, the cooperative protocol is the default. If you are on an older version, you must explicitly configure it.

When migrating from an eager assignor to a cooperative assignor in a running consumer group, you must perform a rolling upgrade:

  • First, configure all consumers with both the old and new assignors (e.g., RangeAssignor, CooperativeStickyAssignor).
  • Perform a rolling restart so every consumer advertises support for both protocols.
  • Then remove the old eager assignor from the configuration and do another rolling restart.

Static Group Membership

By default, every time a consumer restarts it gets a new member.id from the group coordinator. This means even a brief restart triggers a full rebalance. Static group membership (introduced in Kafka 2.3) solves this by assigning a persistent identity to each consumer via group.instance.id.

When a consumer with a group.instance.id disconnects, the group coordinator does not trigger a rebalance immediately. Instead, it waits for session.timeout.ms to expire. If the consumer reconnects with the same instance id before the timeout, it silently rejoins and reclaims its previous partition assignment without any rebalance.

Java
// Static group membership — avoid rebalance on restart
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "order-processing-group");
props.put(ConsumerConfig.GROUP_INSTANCE_ID_CONFIG, "consumer-host-1"); // unique per instance
props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 60000);           // 60s window for restart
props.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
          "org.apache.kafka.clients.consumer.CooperativeStickyAssignor");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
          "org.apache.kafka.common.serialization.StringDeserializer");
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
          "org.apache.kafka.common.serialization.StringDeserializer");

KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(List.of("orders"));
Each group.instance.id must be unique within the consumer group. If two consumers use the same instance id, the second one will be fenced and receive an InstanceAlreadyExistsException. In Kubernetes, the pod name is a common choice for this value.
When using static membership, increase session.timeout.ms to be longer than your typical restart duration. A value of 60-120 seconds is common for rolling deployments. During this window, the partitions owned by the restarting consumer are not consumed by anyone else.

ConsumerRebalanceListener

The ConsumerRebalanceListener interface lets you execute custom logic when partitions are revoked from or assigned to a consumer during a rebalance. The two key callbacks are:

  • onPartitionsRevoked(Collection<TopicPartition>): Called before partitions are taken away. This is where you should commit offsets, flush buffers, or close resources tied to those partitions.
  • onPartitionsAssigned(Collection<TopicPartition>): Called after new partitions have been assigned. Use this to initialize state, seek to a specific offset, or prepare resources for the new partitions.
Java
consumer.subscribe(List.of("orders"), new ConsumerRebalanceListener() {

    @Override
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
        System.out.println("Partitions revoked: " + partitions);

        // Commit current offsets before losing ownership
        consumer.commitSync();

        // Flush any buffered state (e.g., batch inserts)
        flushPendingWrites();
    }

    @Override
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
        System.out.println("Partitions assigned: " + partitions);

        // Optionally seek to a stored offset (e.g., from an external store)
        for (TopicPartition tp : partitions) {
            Long savedOffset = externalStore.getOffset(tp);
            if (savedOffset != null) {
                consumer.seek(tp, savedOffset);
            }
        }
    }
});

With the cooperative protocol there is an additional callback:

  • onPartitionsLost(Collection<TopicPartition>): Called when partitions are lost unexpectedly (e.g., consumer was fenced). Unlike onPartitionsRevoked, the partitions may already be owned by another consumer, so you should not commit offsets here.
Java
consumer.subscribe(List.of("orders"), new ConsumerRebalanceListener() {

    @Override
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
        // Safe to commit — we still own these partitions
        consumer.commitSync(currentOffsets(partitions));
    }

    @Override
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
        // Initialize resources for newly assigned partitions
        initializeState(partitions);
    }

    @Override
    public void onPartitionsLost(Collection<TopicPartition> partitions) {
        // Partitions already moved — do NOT commit offsets
        // Clean up local state only
        cleanupState(partitions);
    }
});
Always commit offsets in onPartitionsRevoked, not in onPartitionsLost. By the time onPartitionsLost is called, another consumer may already own those partitions, and committing stale offsets could cause that consumer to skip messages.

Partition Assignment Strategies

Kafka provides four built-in partition assignors. Each implements a different strategy for distributing partitions across the consumers in a group.

Assignor Protocol Sticky? Behavior
RangeAssignor Eager No Assigns partitions per topic in ranges. Can lead to uneven load when partition counts are not divisible.
RoundRobinAssignor Eager No Round-robin across all topic-partitions. Even distribution but no attempt to preserve prior assignment.
StickyAssignor Eager Yes Balanced like RoundRobin but tries to keep partitions on their existing consumer. Still uses eager (stop-the-world) protocol.
CooperativeStickyAssignor Cooperative Yes Same sticky logic as StickyAssignor but uses the incremental cooperative protocol. Only migrated partitions are revoked.
Properties
# Example: setting the assignor in consumer properties
partition.assignment.strategy=org.apache.kafka.clients.consumer.CooperativeStickyAssignor
The StickyAssignor and CooperativeStickyAssignor produce identical partition mappings. The only difference is the rebalance protocol: eager vs cooperative. If you want sticky assignment with minimal downtime, choose CooperativeStickyAssignor.

Diagnosing Rebalance Storms

A rebalance storm occurs when repeated rebalances fire in quick succession, preventing the consumer group from making progress. This is one of the most common operational issues in Kafka consumer deployments.

Common causes and their fixes:

  • Slow message processing: If processing a batch takes longer than max.poll.interval.ms (default 300 seconds), the consumer is evicted and triggers a rebalance. Fix: increase max.poll.interval.ms or reduce max.poll.records to get smaller batches.
  • Long GC pauses: Garbage collection pauses exceeding session.timeout.ms cause the coordinator to mark the consumer as dead. Fix: tune JVM GC settings, increase session.timeout.ms.
  • Frequent deploys without static membership: Rolling deployments cause a rebalance on every pod restart. Fix: configure group.instance.id for static membership.
  • Network issues: Transient network partitions prevent heartbeats from reaching the coordinator. Fix: verify network stability, increase session.timeout.ms.
  • Too many consumers: More consumers than partitions means idle consumers. Scaling up and down repeatedly triggers rebalances. Fix: ensure consumer count does not exceed partition count.

Key configuration properties to tune:

ConfigDefaultRecommendation
max.poll.interval.ms 300000 (5 min) Set higher than your worst-case batch processing time. Monitor with consumer lag metrics.
max.poll.records 500 Reduce if each record takes significant processing time to stay within the poll interval.
session.timeout.ms 45000 Increase for environments with GC pauses or network jitter. Must be within broker's group.min.session.timeout.ms and group.max.session.timeout.ms.
heartbeat.interval.ms 3000 Should be no more than 1/3 of session.timeout.ms. Allows at least 3 heartbeats before timeout.
Properties
# Tuning to prevent rebalance storms
max.poll.interval.ms=600000
max.poll.records=100
session.timeout.ms=60000
heartbeat.interval.ms=10000
If you see CommitFailedException in your logs, it typically means the consumer was evicted from the group because it exceeded max.poll.interval.ms. The committed offsets were rejected because the consumer is no longer a member of the group. Reduce your processing time or increase the poll interval.

Spring Kafka Configuration

Spring Kafka provides convenient configuration for partition assignment strategies through application properties and programmatic configuration.

Properties
# application.properties — Spring Boot Kafka consumer configuration
spring.kafka.bootstrap-servers=localhost:9092
spring.kafka.consumer.group-id=order-processing-group
spring.kafka.consumer.auto-offset-reset=earliest
spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer
spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer

# Set cooperative sticky assignor
spring.kafka.consumer.properties.partition.assignment.strategy=\
  org.apache.kafka.clients.consumer.CooperativeStickyAssignor

# Static membership for Kubernetes deployments
spring.kafka.consumer.properties.group.instance.id=${HOSTNAME}
spring.kafka.consumer.properties.session.timeout.ms=60000

For programmatic configuration using ConcurrentKafkaListenerContainerFactory:

Java
@Configuration
public class KafkaConsumerConfig {

    @Bean
    public ConsumerFactory<String, String> consumerFactory() {
        Map<String, Object> props = new HashMap<>();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        props.put(ConsumerConfig.GROUP_ID_CONFIG, "order-processing-group");
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        props.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
                  List.of(CooperativeStickyAssignor.class));
        props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 60000);
        props.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 600000);
        props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 100);
        return new DefaultKafkaConsumerFactory<>(props);
    }

    @Bean
    public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory() {
        ConcurrentKafkaListenerContainerFactory<String, String> factory =
                new ConcurrentKafkaListenerContainerFactory<>();
        factory.setConsumerFactory(consumerFactory());
        factory.setConcurrency(3); // number of consumer threads
        return factory;
    }
}

Using @KafkaListener with a rebalance listener:

Java
@Component
public class OrderConsumer {

    @KafkaListener(topics = "orders", groupId = "order-processing-group")
    public void consume(ConsumerRecord<String, String> record,
                        Acknowledgment acknowledgment) {
        processOrder(record.value());
        acknowledgment.acknowledge();
    }
}

@Component
public class RebalanceLogger implements ConsumerAwareRebalanceListener {

    @Override
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
        log.info("Partitions revoked: {}", partitions);
    }

    @Override
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
        log.info("Partitions assigned: {}", partitions);
    }

    @Override
    public void onPartitionsLost(Collection<TopicPartition> partitions) {
        log.warn("Partitions lost: {}", partitions);
    }
}
In Spring Kafka, set spring.kafka.listener.ack-mode=MANUAL when you want to control offset commits yourself. Combined with CooperativeStickyAssignor and a ConsumerAwareRebalanceListener, this gives you full control over offset management during rebalances.