60-Day Kafka 4 Learning Plan · Week 1 — Foundations · Spring Boot Integration Sources: Kafka: The Definitive Guide Ch.4 · kafka.apache.org/43 · Spring Kafka Docs
Goal
Understand consumer groups, the poll loop, offset commit strategies, rebalancing, and implement @KafkaListener patterns in Spring Boot 3 — plus error recovery, lag monitoring, and testing.
1. Consumer Groups
Each consumer in a group is assigned a subset of partitions. The broker coordinates assignment via the Group Coordinator.
Scenario A — 2 consumers, 4 partitions (balanced)
P0 P1 → C1
P2 P3 → C2
Scenario B — 4 consumers, 4 partitions (★ ideal)
P0 → C1 | P1 → C2 | P2 → C3 | P3 → C4
Scenario C — 5 consumers, 4 partitions (⚠ waste)
P0 → C1 | P1 → C2 | P2 → C3 | P3 → C4 | C5 → idle
Golden rule: num_consumers ≤ num_partitions. Each consumer group receives all messages independently — multiple groups on the same topic each get a full copy.
2. Poll Loop & Internals
consumer.subscribe(List.of("orders"));
while (true) {
ConsumerRecords<String, Order> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, Order> r : records) {
process(r);
}
consumer.commitAsync();
}
How it works:
poll()fetches records and sends heartbeats to the brokersession.timeout.ms(default 45s): missing heartbeat window before consumer is declared dead → rebalancemax.poll.interval.ms(default 5min): too slow between polls → also triggers rebalance- Always call
consumer.close()on shutdown to trigger clean rebalance instead of waiting for timeout
Heartbeat vs poll interval: since Kafka 0.10.1, heartbeats run on a separate background thread, decoupled from
poll(). This means a slowprocess()call won’t kill the session as long as heartbeats keep flowing — but it will eventually breachmax.poll.interval.msif processing takes too long, triggering a rebalance anyway. Both timeouts matter, for different failure modes.
3. Offset Commit Modes
Auto Commit
enable.auto.commit: true
auto.commit.interval.ms: 5000
- Easiest but ⚠ risk of duplicates if consumer crashes between commit intervals
Sync Commit
consumer.commitSync(); // blocks until broker ACKs, retries on failure
- Safe but slower — use for exactly-once requirements
Async + Callback ★ Recommended
consumer.commitAsync((offsets, exception) -> {
if (exception != null) {
log.error("Commit failed for offsets {}", offsets, exception);
}
});
- Non-blocking with error visibility
- Pair with a final
commitSync()on consumer close
Spring Boot AckMode

At-least-once vs exactly-once: committing after processing (the pattern above) gives at-least-once delivery — a crash between processing and commit causes reprocessing on restart. True exactly-once requires either idempotent downstream writes (dedupe by message key) or a transactional consume-transform-produce pipeline (§9).
4. Rebalancing
Triggers:
- Consumer joins or leaves the group
- Consumer crashes (heartbeat timeout exceeded)
- New partitions added to a topic
Eager Rebalance (classic — stop-the-world)
1. All consumers revoke their partitions
2. Group Leader proposes new assignment
3. All consumers rejoin and accept assignment
4. Consuming resumes
⚠ Full pause during rebalance — no messages processed.
Cooperative Rebalance (Kafka 2.4+ — incremental)
Only the partitions being moved are revoked.
Other partitions keep being consumed — no global pause.
Enable with: partition.assignment.strategy=CooperativeStickyAssignor
Static Membership — avoid unnecessary rebalances
For rolling deploys/restarts, a consumer leaving and rejoining within session.timeout.ms still triggers a rebalance by default. Static membership fixes this by giving each consumer instance a stable identity:
spring.kafka.consumer:
properties:
group.instance.id: order-svc-pod-${HOSTNAME} # stable per instance
With a group.instance.id set, a brief disconnect (pod restart, network blip) is treated as the same member returning — no rebalance is triggered as long as it rejoins within session.timeout.ms. Essential for Kubernetes rolling deploys with many partitions.
5. Spring Boot Config & @KafkaListener
application.yml
spring:
kafka:
consumer:
group-id: my-group
auto-offset-reset: earliest
enable-auto-commit: false
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
properties:
spring.json.trusted.packages: "*"
listener:
ack-mode: BATCH
Single Record Listener
@KafkaListener(topics = "orders", groupId = "order-svc")
public void consume(ConsumerRecord<String, Order> record) {
log.info("Received order={} at offset={}", record.value(), record.offset());
// process...
}
Batch Listener
@KafkaListener(topics = "events", containerFactory = "batchFactory")
public void consumeBatch(List<Event> events, Acknowledgment ack) {
events.forEach(e -> process(e));
ack.acknowledge(); // manual commit after batch
}
ConcurrentKafkaListenerContainerFactory (for batch mode)
@Bean
public ConcurrentKafkaListenerContainerFactory<String, Event> batchFactory(
ConsumerFactory<String, Event> cf) {
var factory = new ConcurrentKafkaListenerContainerFactory<String, Event>();
factory.setConsumerFactory(cf);
factory.setBatchListener(true);
factory.getContainerProperties().setAckMode(AckMode.MANUAL_IMMEDIATE);
return factory;
}
6. Error handling — retries, poison pills, and DLQ
A poison pill is a message that always fails processing (bad JSON, unexpected schema, business rule violation). Without a strategy, it blocks the partition forever — the consumer keeps retrying the same offset.
DefaultErrorHandler + dead-letter topic
@Bean
public DefaultErrorHandler errorHandler(KafkaTemplate<String, Object> template) {
var recoverer = new DeadLetterPublishingRecoverer(template,
(record, ex) -> new TopicPartition(record.topic() + ".DLT", record.partition()));
// retry 3 times, 1s apart, then send to DLT
var handler = new DefaultErrorHandler(recoverer, new FixedBackOff(1000L, 3));
// don't retry business/deserialization errors — straight to DLT
handler.addNotRetryableExceptions(DeserializationException.class, IllegalArgumentException.class);
return handler;
}
@Bean
public ConcurrentKafkaListenerContainerFactory<String, Order> kafkaListenerContainerFactory(
ConsumerFactory<String, Order> cf, DefaultErrorHandler errorHandler) {
var factory = new ConcurrentKafkaListenerContainerFactory<String, Order>();
factory.setConsumerFactory(cf);
factory.setCommonErrorHandler(errorHandler);
return factory;
}
Deserialization poison pills need special handling since the exception happens before your listener code runs — use ErrorHandlingDeserializer as a wrapper:
spring.kafka.consumer:
key-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
properties:
spring.deserializer.key.delegate.class: org.apache.kafka.common.serialization.StringDeserializer
spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JsonDeserializer
This lets the broken record flow into the error handler (and DLT) instead of crashing the container on startup.
7. Monitoring consumer lag
Consumer lag = latest offset in the partition − last committed offset. It’s the single most important consumer health metric — rising lag means the consumer can’t keep up with the producer.
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--describe --group order-svc

For dashboards, expose lag via Micrometer:
@Bean
public MeterRegistryCustomizer<MeterRegistry> kafkaConsumerMetrics(
ConcurrentKafkaListenerContainerFactory<?, ?> factory) {
return registry -> factory.getContainerProperties()
.setMicrometerEnabled(true); // auto-exposes kafka.consumer.* metrics
}

Rule of thumb: lag that grows faster than it drains during peak traffic means you’re under-provisioned on partitions/consumers, not just experiencing a temporary blip.
8. Multi-threading models

factory.setConcurrency(3); // 3 threads, each consuming a share of the partitions
concurrencyshould never exceed the topic’s partition count — extra threads sit idle (same waste as Scenario C in §1).
9. Exactly-once consume-transform-produce
When a consumer reads a message, transforms it, and produces a new message — and both must succeed or fail together — combine a transactional producer (Day 5, §4) with the consumer’s offset commit:
kafkaTemplate.executeInTransaction(ops -> {
ops.sendOffsetsToTransaction(currentOffsets, consumerGroupMetadata); // commit offset AS PART of the transaction
ops.send("enriched-orders", key, enrichedPayload);
return true;
});
This way, the input offset commit and the output message are atomic — a crash mid-way means neither happened, so on restart the same input record is reprocessed cleanly (no half-applied state). Downstream consumers must use isolation.level=read_committed to only see the finished result.
10. Testing consumers
@SpringBootTest
@EmbeddedKafka(partitions = 3, topics = "orders")
class ConsumerIntegrationTest {
@Autowired KafkaTemplate<String, Order> kafkaTemplate;
@SpyBean OrderListener orderListener; // the @KafkaListener bean
@Test
void consumesAndProcessesOrder() {
kafkaTemplate.send("orders", "key1", new Order("123"));
await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
verify(orderListener).consume(any())
);
}
}
Use
awaitility(await().untilAsserted(...)) instead ofThread.sleep()— consumption is asynchronous, and polling for the assertion avoids flaky, slow tests.
11. Common pitfalls
- Long processing inside the listener without adjusting
max.poll.interval.ms— triggers unwanted rebalances mid-processing - Auto-commit with slow processing — offset commits on a timer, not tied to actual completion, risking data loss on crash
- Ignoring poison pills — one bad message with no DLT strategy can permanently stall a partition
concurrency> partition count — wasted threads that never receive work- Not setting
group.instance.idin Kubernetes — every rolling deploy causes a full rebalance storm across the whole group - Mixing manual and auto commit — e.g. calling
ack.acknowledge()whileenable-auto-commit: trueis still set, causing confusing double-commit behavior
12. Key Consumer Configs

Key Takeaways
- Consumer groups allow parallel processing — scale consumers up to
num_partitionsmax - Offset commits track progress — use
BATCHAckMode in Spring Boot withenable-auto-commit: false - Rebalances redistribute partitions when group membership changes — Cooperative Rebalance (Kafka 2.4+) and static membership both minimize disruption
- Poison pills need a
DefaultErrorHandler+ DLT strategy, or they’ll stall a partition indefinitely - Consumer lag is the primary health signal — monitor
records-lag-maxcontinuously, not just on incidents - True exactly-once needs transactional offset commits (
sendOffsetsToTransaction), not just idempotent producers - Always close consumers cleanly to avoid waiting for
session.timeout.ms
Support me through GitHub Sponsors.
Next
➡️ Day 7 — First Project: Spring Boot Kafka Lab — producer + consumer end-to-end
Resources
- 📘 Kafka: The Definitive Guide — Chapter 4 (Kafka Consumers)
- 🌐 kafka.apache.org/43/documentation/#consumerconfigs
- 🌐 Spring Kafka — @KafkaListener
- 🌐 Spring Kafka — Error Handling