Kafka
Consumer
Single Threaded
Multi Threaded
Programming

How to write Kafka consumers - single threaded vs multi threaded

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design

Introduction

Kafka consumer design is mostly a tradeoff between simplicity and throughput. A single-threaded consumer is easier to reason about because polling, processing, and committing offsets happen in one place, while a multi-threaded design can increase throughput but requires deliberate control of partition ownership, ordering, and offset commits.

The Safe Baseline: One Consumer Per Thread

The Kafka Java KafkaConsumer is not thread-safe. That fact drives the design.

The simplest correct model is one thread, one consumer instance, polling records and processing them sequentially.

java
1while (running) {
2    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
3    for (ConsumerRecord<String, String> record : records) {
4        handle(record);
5    }
6    consumer.commitSync();
7}

This is easy to debug and preserves per-partition ordering naturally. It is a good fit when processing is lightweight or when correctness matters more than raw throughput.

When Multi-Threading Helps

Multi-threading becomes attractive when per-record processing is expensive and the polling thread cannot keep up. But the consumer itself should still generally stay confined to one thread. The common pattern is:

  1. One polling thread owns the consumer.
  2. Records are handed off to worker threads.
  3. Offset commits happen only after worker completion is tracked safely.

That separation matters because calling consumer methods from arbitrary worker threads is a common mistake.

A Worker-Pool Pattern

A simplified architecture looks like this:

java
1ExecutorService pool = Executors.newFixedThreadPool(8);
2
3while (running) {
4    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
5    for (TopicPartition partition : records.partitions()) {
6        List<ConsumerRecord<String, String>> batch = records.records(partition);
7        pool.submit(() -> processPartitionBatch(batch));
8    }
9    // Commit only after completed work is tracked correctly.
10}

The key idea is to keep partition ordering intact by submitting work per partition rather than shuffling all records across threads arbitrarily.

Offset Commit Strategy Is the Real Complexity

The hardest part of multi-threaded consumption is not creating threads. It is deciding when an offset is safe to commit.

If you commit too early, a crash can lose unprocessed records. If you commit too late, you increase duplicate processing after restart. A correct design usually tracks the highest fully processed offset per partition and commits only those safe positions.

This is why many teams stay single-threaded longer than they expected. The throughput gain is real, but so is the coordination cost.

Partition Count Still Limits Parallelism

No matter how many worker threads you create, a consumer group cannot process more partitions in parallel than actually exist. If a topic has three partitions, spawning twenty consumer threads does not create twenty independent streams of ordered work.

So before building a complicated multi-threaded consumer, check whether the topic partition count is already the real throughput limit.

When to Scale Out Instead of Threading Up

A lot of Kafka consumer code becomes complicated because teams try to force more concurrency into one process before considering consumer-group scaling. Sometimes the simpler answer is to run more consumer instances and let Kafka distribute partitions naturally. That keeps ordering and offset management closer to the platform model instead of re-implementing concurrency control inside one application.

Common Pitfalls

  • Sharing one KafkaConsumer instance across multiple threads even though it is not thread-safe.
  • Parallelizing records from the same partition in a way that breaks ordering guarantees.
  • Committing offsets before worker threads have actually finished processing.
  • Adding many worker threads when the topic has too few partitions to benefit.
  • Choosing a complex multi-threaded design before measuring whether a simpler consumer is actually too slow.

Summary

  • Start with a single-threaded consumer unless throughput measurements prove it is insufficient.
  • The consumer instance itself should usually stay confined to one polling thread.
  • Multi-threaded designs hand work to worker threads but keep offset management explicit.
  • Preserve per-partition ordering when parallelizing processing.
  • Partition count and commit strategy matter as much as thread count.

Related reading
Course
Beginner
27 lessons
10 hours
System Design Fundamentals

Build a strong foundation in designing scalable, reliable distributed systems.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design

All Rights Reserved.