Kafka consumers are the workhorses of event-driven architectures, but building reliable ones is surprisingly tricky. The most surprising thing about reliable Kafka consumers is that "at least once" delivery is often the default and the most achievable guarantee, yet many engineers chase "exactly once" without understanding the immense complexity and performance trade-offs.
Let’s see a consumer in action, processing messages and committing offsets. Imagine we have a topic named user-events with messages like this:
{"user_id": "abc-123", "event_type": "login", "timestamp": 1678886400}
{"user_id": "xyz-789", "event_type": "logout", "timestamp": 1678886500}
Our consumer code, in Java using the kafka-clients library, might look something like this:
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Collections.singletonList("user-events"));
try {
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
for (ConsumerRecord<String, String> record : records) {
System.printf("Processing offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
// Simulate processing work
processUserEvent(record.value());
}
// Commit offsets after processing all fetched records
consumer.commitSync();
}
} finally {
consumer.close();
}
Here, props would contain your Kafka broker addresses, group ID, key/value deserializers, and importantly, enable.auto.commit=false to give us control.
The problem this solves is decoupling producers from consumers. Producers can spew events as fast as they want, and consumers can process them at their own pace. The key to reliability lies in how consumers track their progress: offsets. An offset is simply a unique identifier for a message within a Kafka partition. When a consumer processes a message, it needs to tell Kafka, "I’ve successfully processed up to this offset." This is called committing an offset.
Internally, Kafka stores these committed offsets for each consumer group. This allows a consumer to restart and pick up exactly where it left off, even if it crashes or is restarted. The magic here is that Kafka guarantees that messages within a partition are delivered in order. So, if you commit offset N, you’re implicitly guaranteeing that all messages from offset 0 to N have been processed.
The core levers you control are:
group.id: This defines a consumer group. Consumers with the samegroup.idshare the processing load for a topic. If one consumer in a group fails, others can take over its partitions.enable.auto.commit: Iftrue(default), Kafka automatically commits offsets periodically. This is convenient but dangerous: if your consumer crashes after polling messages but before processing them, you might lose data because the offset was committed. Setting this tofalseis crucial for reliable processing.- Commit Strategy: When
enable.auto.commitisfalse, you must manually commit.commitSync()commits synchronously, retrying until successful.commitAsync()is non-blocking but requires careful handling of callbacks to detect failures. - Offset Management: You can commit after processing each message (leading to "at least once" delivery with potential duplicates) or after processing a batch of messages (more efficient, still "at least once"). "Exactly once" semantics require more intricate coordination, often involving Kafka transactions or idempotent producers/consumers.
max.poll.records: Controls how many records are fetched in a single poll. Larger batches can improve throughput but increase the time between polls, potentially leading to rebalances if a consumer becomes unresponsive.max.poll.interval.ms: The maximum time betweenpoll()calls before the consumer is considered failed and triggers a rebalance. This is critical for preventing stale consumers from holding onto partitions.
The one thing most people don’t fully grasp is the interplay between max.poll.interval.ms and your processing time. If your processing for a batch of records fetched by poll() takes longer than max.poll.interval.ms, the consumer will be assumed dead by the broker, triggering a rebalance. Even though your consumer is still alive and processing, it will lose its partitions. You need to ensure your processing loop, including any external calls or heavy computation, completes within this interval, or implement logic to periodically call consumer.poll(Duration.ofMillis(0)) (a "heartbeat poll") during long processing to signal liveness.
Understanding consumer rebalances is the next hurdle.