At 6:12 PM on a Friday, our order-service dashboard looked healthy, but support was already in panic mode. A handful of customers were getting duplicate order-confirmation emails, and a few shipments were delayed because inventory events arrived twice for the same order key. Nothing was fully down, which made it worse. The root cause was subtle: a consumer-group rebalance happened while one Java consumer instance was still finishing slow I/O, and our offset strategy was optimistic enough to create a tiny but expensive gap.
If you run Kafka consumers in production, this is the kind of issue that does not show up in happy-path demos. It appears during deploy windows, broker hiccups, noisy neighbors, or one bad downstream API. In this guide, I will show the exact hardening pattern we now use for Java + Spring Kafka consumers so rebalances become boring instead of incident-worthy.
The focus keyword for this article is Java Kafka consumer rebalancing. We will also cover cooperative sticky assignor, idempotent consumer pattern, and Spring Kafka error handling in practical terms.
Why rebalances hurt more than we admit
Kafka consumer groups rebalance whenever members join, leave, crash, or change subscriptions. In eager rebalancing, everyone pauses and partitions can move broadly. Incremental cooperative rebalancing (KIP-429) reduces unnecessary partition migration by revoking only partitions that must move, which cuts disruption for busy consumers. It is not magic, but it is a meaningful default for real systems.
Two realities matter in Java services:
- The Kafka consumer client is not thread-safe. The poll loop and offset lifecycle need discipline.
- If your processing exceeds
max.poll.interval.ms, the broker can evict the consumer and trigger rebalancing at exactly the wrong time.
If this sounds familiar, skim these related playbooks after this article: backend reliability patterns, our Java outbox write-up, and an idempotency-first webhook design in Node.js.
The stabilization recipe we adopted
We stopped trying to fix this with one configuration tweak. Instead, we combined four layers:
- Incremental cooperative assignment to reduce rebalance blast radius.
- Static membership where appropriate, to avoid churn on rolling restarts.
- Manual acknowledgements only after durable processing.
- An idempotency store so duplicate delivery becomes harmless.
1) Consumer config that survives real deploys
spring:
kafka:
consumer:
group-id: orders-consumer-v2
enable-auto-commit: false
auto-offset-reset: earliest
max-poll-records: 200
properties:
partition.assignment.strategy: org.apache.kafka.clients.consumer.CooperativeStickyAssignor
# Use static membership only if each instance has a stable identity.
group.instance.id: orders-consumer-${HOSTNAME}
session.timeout.ms: 45000
heartbeat.interval.ms: 3000
max.poll.interval.ms: 300000
listener:
ack-mode: manual
concurrency: 3
Tradeoff note: static membership helps reduce unnecessary rebalances during restarts, but only when instance identities are stable. In ephemeral autoscaling setups, bad identity hygiene can cause delayed partition movement and confusing ownership behavior.
2) Commit only after idempotent, durable work
@Component
@RequiredArgsConstructor
public class OrderEventsListener {
private final JdbcTemplate jdbc;
private final InventoryService inventoryService;
@KafkaListener(topics = "orders.events", groupId = "orders-consumer-v2")
@Transactional
public void onMessage(ConsumerRecord<String, String> record, Acknowledgment ack) {
String eventId = record.headers().lastHeader("event-id") == null
? record.topic() + ":" + record.partition() + ":" + record.offset()
: new String(record.headers().lastHeader("event-id").value(), StandardCharsets.UTF_8);
int inserted = jdbc.update(
"""
INSERT INTO processed_events(event_id, processed_at)
VALUES (?, now())
ON CONFLICT (event_id) DO NOTHING
""", eventId);
if (inserted == 0) {
// Duplicate event, already processed safely.
ack.acknowledge();
return;
}
inventoryService.reserveForOrder(record.value());
ack.acknowledge();
}
}
This is the core idempotent consumer pattern. Even if a rebalance, retry, or restart causes redelivery, your side effects stay correct.
3) Rebalance hooks for cleaner handoff
@Bean
public ConsumerAwareRebalanceListener rebalanceListener(KafkaTemplate<String, String> template) {
return new ConsumerAwareRebalanceListener() {
@Override
public void onPartitionsRevokedBeforeCommit(Consumer<?, ?> consumer,
Collection<TopicPartition> partitions) {
// Flush producer-side outbox or in-memory buffers if used.
template.flush();
// For manual commit flows, commitSync at controlled boundaries if needed.
}
@Override
public void onPartitionsAssigned(Consumer<?, ?> consumer,
Collection<TopicPartition> partitions) {
log.info("Assigned partitions: {}", partitions);
}
};
}
You might not need all callbacks, but explicit partition lifecycle handling makes incident timelines much easier to debug.
How this changes failure behavior
Before hardening, our failure mode was binary: either “works fine” or “mysterious duplicates during churn.” After hardening, failures became slower but safer. If downstream APIs lag, throughput drops first. If a consumer restarts, ownership shifts with less turbulence. If a message is replayed, business state stays consistent.
That is the right tradeoff for most systems that process money, inventory, billing, or identity events. Correctness first, then speed.
The tradeoffs, clearly
These safeguards are not free. Cooperative rebalancing reduces disruption, but convergence after a topology change can take more than one rebalance cycle. Manual acknowledgements improve correctness, but they force discipline around transaction boundaries and observability. Idempotency tables protect business state, but they add write load and require retention strategy, especially at high throughput.
The practical move is to track three metrics together, not in isolation: partition lag, rebalance frequency, and duplicate-detection hit rate. If lag goes up while duplicate hits go down, you likely tightened safety at the cost of throughput. If duplicate hits spike during deploys, your ownership handoff or ack timing is still too loose.
Troubleshooting: when results still look wrong
Symptom: frequent rebalances during normal traffic
- Check processing latency versus
max.poll.interval.ms. - Lower
max.poll.recordsor reduce batch work per poll. - Verify all consumers in the group use the same assignment strategy.
Symptom: duplicates still appear in downstream systems
- Confirm idempotency key uniqueness, do not rely on payload hash alone if payload can evolve.
- Ensure the idempotency write and side effect are in a consistent transaction boundary where possible.
- Validate that acknowledgements happen only after successful durable processing.
Symptom: slow recovery after deploys
- Revisit static membership strategy and instance identity stability.
- Check session timeout and heartbeat values against broker limits.
- Inspect consumer lag by partition, one hot partition can mimic group instability.
If your batch side also runs on serverless pipelines, the resilience ideas in this incident write-up pair nicely with the consumer-side tactics above.
FAQ
1) Should I always enable CooperativeStickyAssignor?
For most modern consumer groups, yes, because it reduces unnecessary partition movement. Still test mixed-client-version environments before rollout, and keep configs consistent across all group members.
2) Does static membership eliminate rebalances completely?
No. It reduces avoidable rebalances during predictable restarts, but broker events, scaling changes, and topology changes still trigger reassignments.
3) Can I skip idempotency if I use exactly-once semantics?
Not always. Exactly-once guarantees are powerful, but end-to-end behavior across external services, databases, and side effects can still produce duplicates unless your consumer side effects are idempotent.
Actionable takeaways for this week
- Switch one production consumer group to
CooperativeStickyAssignorin staging and observe rebalance metrics. - Audit ack timing, move acknowledgements to post-durability points only.
- Add an idempotency table with a unique key and explicit duplicate handling path.
- Create a rebalance runbook with partition revoke/assign logs and lag snapshots.
- Load test one slow-downstream scenario to validate
max.poll.interval.msbehavior before the next release.

Leave a Reply