00:00

How can Kafka avoid duplicate messages?

A Kafka consumer is a component in Apache Kafka that reads (consumes) messages from Kafka topics. Simply put, if a producer sends data to Kafka, the consumer is the one that receives and processes that data.

This guide explains how consumers work, how consumer groups share partitions, how offsets and commits affect delivery guarantees, and how to handle rebalancing, shutdown, errors and threads, with formatted Java and Spring Boot examples. It pairs with Kafka Producer and builds on Kafka Partition.

1. What Is a Kafka Consumer?

Think of Kafka as a newspaper delivery system. The producer publishes newspapers, Kafka stores them, and the consumer reads the newspapers whenever it wants. Unlike a real newspaper, reading does not remove the paper: other readers can read the same copy, and the same reader can go back to an older edition.

Producer, Kafka and consumers
  Order Service           Kafka topic "orders"            Consumers
  (producer)    ----->   [P0] [P1] [P2]       ----->   Billing Service
                                              ----->   Email Service

2. How Does a Kafka Consumer Work?

A Kafka consumer connects to a Kafka cluster and subscribes to one or more topics. It then runs a poll loop, continuously asking Kafka for new messages and processing them:

The poll loop
        +--------------------------------------------------+
        |                                                  |
        v                                                  |
  poll() --> get a batch of records --> process each --> commit offsets
  (fetch from the partition leaders)                    (save progress)
  • The consumer asks Kafka for new messages with poll().
  • Kafka returns the next batch of messages, in order within each partition.
  • The consumer processes the messages.
  • The consumer keeps track of what it has already read by committing offsets.

Calling poll() regularly also tells Kafka that the consumer is still alive and working.

3. Key Features of a Kafka Consumer

FeatureExplanation
Pull-based consumptionConsumers pull messages instead of Kafka pushing them, so each consumer reads at the speed it can handle
Consumer groupsConsumers can work together in a group; each message is read by only one consumer in the group, which balances the load
Message orderingMessages are consumed in the same order they were written, within a single partition
Offset managementEach group tracks its position with offsets, so consumers resume where they left off, even after a failure
Fault toleranceIf a consumer crashes, another consumer in the same group takes over its partitions and continues from the last committed offset
ScalabilityAdd more consumers to a group and Kafka automatically spreads the partitions among them
Delivery guaranteesAt-most-once, at-least-once or exactly-once processing, depending on configuration
ReplayConsumers can move back to earlier offsets and read messages again while they are retained

4. Writing a Kafka Consumer in Java

Add the Kafka client library to your project:

pom.xml
<dependency>
    <groupId>org.apache.kafka</groupId>
    <artifactId>kafka-clients</artifactId>
    <version>4.0.0</version>
</dependency>

This consumer reads order messages from the orders topic as part of the billing-service group:

OrderConsumer.java
package com.example.kafka;

import java.time.Duration;
import java.util.List;
import java.util.Properties;

import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.common.serialization.StringDeserializer;

public class OrderConsumer {

    public static void main(String[] args) {
        // 1. Configure the consumer
        Properties props = new Properties();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        props.put(ConsumerConfig.GROUP_ID_CONFIG, "billing-service");
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);

        // 2. Create the consumer and subscribe to the topic
        try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props)) {
            consumer.subscribe(List.of("orders"));

            // 3. Poll loop
            while (true) {
                ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));

                for (ConsumerRecord<String, String> record : records) {
                    System.out.printf("Order %s -> %s (partition=%d, offset=%d)%n",
                            record.key(), record.value(), record.partition(), record.offset());
                    createInvoice(record.value());
                }

                // 4. Save progress after the batch is processed
                if (!records.isEmpty()) {
                    consumer.commitSync();
                }
            }
        }
    }

    private static void createInvoice(String order) {
        // business logic
    }
}
PartWhat it does
group.idName of the consumer group; consumers with the same ID share the work
DeserializersTurn the stored bytes back into Java objects (here, Strings)
auto.offset.reset=earliestA brand-new group starts from the oldest available message
subscribe()Joins the group; Kafka assigns partitions to this consumer
poll()Returns the next batch of records, waiting up to 500 ms if none are ready
commitSync()Saves the group’s position only after processing succeeds

5. Important Consumer Settings

SettingDefaultMeaning
bootstrap.servers–Brokers to connect to first (required)
group.id–Consumer group name; needed for subscribe() and commits
key.deserializer / value.deserializer–How to convert bytes to objects (required)
auto.offset.resetlatestWhere to start when the group has no committed offset
enable.auto.committrueCommit offsets automatically every auto.commit.interval.ms (5 s)
max.poll.records500Maximum records returned by one poll()
max.poll.interval.ms300000 (5 min)Maximum time between polls before the consumer is removed from the group
session.timeout.ms45000How long without heartbeats before the consumer is considered dead
fetch.min.bytes / fetch.max.wait.ms1 / 500How much data to wait for, and for how long, before a fetch returns
isolation.levelread_uncommittedSet to read_committed to skip messages from aborted transactions

6. Consumer Groups and Partition Assignment

Consumers with the same group.id form a consumer group. Kafka assigns each partition of the subscribed topics to exactly one member of the group:

PartitionsConsumers in groupResult
61One consumer reads all 6 partitions
63Each consumer reads 2 partitions
66Each consumer reads 1 partition
686 consumers read 1 partition each; 2 consumers stay idle as standbys

Different groups are completely independent: a billing-service group and an email-service group both receive every message of the topic.

The partition.assignment.strategy setting chooses how partitions are divided. The Java client’s default list includes the cooperative sticky assignor, which keeps existing assignments where possible and moves only the partitions that need to move. Kafka 4.0 also offers a newer group protocol (group.protocol=consumer) in which the broker calculates the assignment.

7. Offsets and Commits

Each record has an offset, its position in the partition. The group saves its progress by committing the offset of the next record to read. After a restart or rebalance, reading continues from the committed offset.

Commit styleHowTrade-off
Automaticenable.auto.commit=true; the client commits every 5 seconds during poll()Simple; a crash can cause some repeated messages
Manual, synchronouscommitSync() after processingReliable; waits for the broker
Manual, asynchronouscommitAsync() after processingFaster; failed commits are not retried

See Kafka Offset for committing specific offsets, resetting offsets and replaying data.

8. Delivery Guarantees

GuaranteeHow to get itRisk
At-most-onceCommit before processingMessages can be lost if the consumer crashes during processing
At-least-onceCommit after processing (the usual choice)Messages can be processed twice after a crash
Exactly-onceRead-process-write with a transactional producer and isolation.level=read_committedOnly for Kafka-to-Kafka pipelines; more setup

With at-least-once, make processing idempotent: for example, store the order ID with each invoice and skip orders that already have one. Then a repeated message causes no harm.

9. Rebalancing and How to Avoid Surprises

A rebalance moves partitions between the members of a group. It happens when a consumer joins, leaves, crashes or is considered stuck. During a rebalance, the affected partitions pause briefly.

CauseSetting involvedHow to avoid surprises
Processing a batch takes too longmax.poll.interval.ms (5 min)Lower max.poll.records or speed up processing
Consumer stops sending heartbeatssession.timeout.ms (45 s)Check for long garbage-collection pauses or network problems
Instances restart during deploymentsgroup.instance.idStatic membership: a restarted instance gets its partitions back without a full rebalance
Consumer leaves without closing–Always call close() so the group reacts immediately

To run code when partitions are assigned or taken away, for example to commit progress, pass a ConsumerRebalanceListener to subscribe().

10. Shutting Down a Consumer Cleanly

A consumer should leave its group cleanly when the application stops, so its partitions are reassigned immediately and its last offsets are committed. wakeup() is the only consumer method that is safe to call from another thread; it makes a blocked poll() throw a WakeupException:

GracefulConsumer.java
public class GracefulConsumer {

    public static void main(String[] args) {
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(ConsumerProps.create("billing-service"));
        Thread mainThread = Thread.currentThread();

        // Runs when the JVM is asked to stop (for example Ctrl+C or a container stop)
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            consumer.wakeup();
            try {
                mainThread.join();                    // wait for the poll loop to finish
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }));

        try {
            consumer.subscribe(List.of("orders"));
            while (true) {
                ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
                records.forEach(record -> System.out.println("Processing " + record.value()));
                consumer.commitAsync();
            }
        } catch (WakeupException e) {
            // expected during shutdown
        } finally {
            try {
                consumer.commitSync();                // final, reliable commit
            } finally {
                consumer.close();                     // leave the group immediately
            }
        }
    }
}

Here ConsumerProps.create() is a small helper that returns the same properties as in section 4.

11. Reading JSON Messages

Most messages carry JSON. A small custom deserializer turns the bytes into a Java object, using the Jackson library:

OrderEvent.java
package com.example.kafka;

import java.math.BigDecimal;

public record OrderEvent(String orderId, String customerId, BigDecimal amount) {
}
OrderEventDeserializer.java
package com.example.kafka;

import java.io.IOException;

import org.apache.kafka.common.errors.SerializationException;
import org.apache.kafka.common.serialization.Deserializer;

import com.fasterxml.jackson.databind.ObjectMapper;

public class OrderEventDeserializer implements Deserializer<OrderEvent> {

    private final ObjectMapper mapper = new ObjectMapper();

    @Override
    public OrderEvent deserialize(String topic, byte[] data) {
        if (data == null) {
            return null;
        }
        try {
            return mapper.readValue(data, OrderEvent.class);
        } catch (IOException e) {
            throw new SerializationException("Invalid order JSON on topic " + topic, e);
        }
    }
}

Register it with value.deserializer=com.example.kafka.OrderEventDeserializer and declare the consumer as KafkaConsumer<String, OrderEvent>.

12. Handling Errors and Bad Messages

Two kinds of problems are common:

  • A message that cannot be deserialized (a “poison pill”), such as broken JSON. poll() throws a RecordDeserializationException, which includes the partition and offset of the bad record.
  • Processing fails, for example because a database is temporarily down, or because the data is invalid.
Skipping a poison pill
try {
    ConsumerRecords<String, OrderEvent> records = consumer.poll(Duration.ofMillis(500));
    process(records);
} catch (RecordDeserializationException e) {
    log.error("Bad record at {} offset {}", e.topicPartition(), e.offset(), e);
    consumer.seek(e.topicPartition(), e.offset() + 1);   // move past the bad record
}
Failure typeRecommended handling
Temporary (database or service down)Retry a few times with a pause between attempts
Permanent (invalid data)Send the message to a dead-letter topic with error details, then continue
Poison pill (cannot deserialize)Log it, store the raw bytes if possible, and skip it

Never let one bad message stop the whole consumer, and never skip messages silently: always log them or send them somewhere for investigation.

13. Consumers and Multithreading

KafkaConsumer is not thread-safe. Calling it from several threads at once causes an error. There are two simple ways to process in parallel:

ApproachHowNotes
One consumer per threadStart several consumers with the same group.id, each in its own threadSimple; parallelism is limited by the partition count
One consumer, worker threadsOne thread polls and hands records to a thread poolMore parallelism, but commits and ordering need careful handling
ConsumerPool.java
public class ConsumerPool {

    public static void main(String[] args) {
        int threads = 3;                                  // at most the number of partitions
        ExecutorService executor = Executors.newFixedThreadPool(threads);

        for (int i = 0; i < threads; i++) {
            executor.submit(() -> {
                try (KafkaConsumer<String, String> consumer =
                             new KafkaConsumer<>(ConsumerProps.create("billing-service"))) {
                    consumer.subscribe(List.of("orders"));
                    while (!Thread.currentThread().isInterrupted()) {
                        for (ConsumerRecord<String, String> record : consumer.poll(Duration.ofMillis(500))) {
                            System.out.printf("%s handled partition %d offset %d%n",
                                    Thread.currentThread().getName(), record.partition(), record.offset());
                        }
                        consumer.commitSync();
                    }
                }
            });
        }
    }
}

Each thread has its own consumer, so Kafka assigns different partitions to each one. Order is kept within every partition.

14. Kafka Consumer in Spring Boot

Spring Boot manages consumers for you. Add spring-kafka, configure it, and annotate a method with @KafkaListener:

application.yml
spring:
  kafka:
    bootstrap-servers: localhost:9092
    consumer:
      group-id: billing-service
      auto-offset-reset: earliest
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
OrderListener.java
package com.example.kafka;

import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

@Component
public class OrderListener {

    private final InvoiceService invoiceService;

    public OrderListener(InvoiceService invoiceService) {
        this.invoiceService = invoiceService;
    }

    @KafkaListener(topics = "orders", concurrency = "3")
    public void onOrder(ConsumerRecord<String, String> record) {
        invoiceService.createInvoice(record.key(), record.value());
    }
}

concurrency = "3" starts three consumers in the group, each on its own thread. Spring commits offsets after the listener returns successfully. To retry failed records and then send them to a dead-letter topic, define an error handler bean:

KafkaErrorConfig.java
package com.example.kafka;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer;
import org.springframework.kafka.listener.DefaultErrorHandler;
import org.springframework.util.backoff.FixedBackOff;

@Configuration
public class KafkaErrorConfig {

    @Bean
    public DefaultErrorHandler errorHandler(KafkaTemplate<Object, Object> template) {
        DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(template);
        // retry 3 more times, 2 seconds apart, then publish to the dead-letter topic
        return new DefaultErrorHandler(recoverer, new FixedBackOff(2000L, 3));
    }
}

Spring Boot attaches this error handler to the listener containers automatically. Failed records go to a topic with a .DLT suffix, such as orders.DLT.

15. Simple Example of Kafka Consumers

Imagine an online shopping application:

  • The producer sends order details to a Kafka topic.
  • A Kafka consumer reads the order messages, processes the order and updates the database.
  • Another consumer sends a confirmation email.
Consumer groupInstancesResponsibility
order-processing3Saves orders and updates stock
email-service1Sends confirmation emails
analytics2Updates sales dashboards

Each group receives every order and handles its own responsibility without affecting the others. If the email service is down for an hour, orders are still processed; when it comes back, it continues from its last committed offset and sends the delayed emails.

16. Monitoring Consumer Lag

Consumer lag is how many messages a group still has to read. Check it with the command-line tool:

Check consumer lag
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --describe --group billing-service
Sample output
GROUP            TOPIC   PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG
billing-service  orders  0          1520            1525            5
billing-service  orders  1          1498            1498            0
billing-service  orders  2          1310            1902            592

Low, steady lag is healthy. Growing lag means consumers cannot keep up: add consumers (up to the number of partitions), speed up processing, or look for a stuck consumer on the lagging partition.

17. When Should You Use a Kafka Consumer?

  • When you need to process large volumes of data.
  • When systems must work independently of each other.
  • When you need reliable and fault-tolerant message processing.
  • When several applications need the same data.
  • When scalability is important.
  • When you may need to reprocess past events later.

18. Best Practices and Common Mistakes

DoAvoid
Commit offsets after processingCommitting before processing important data
Make processing idempotentAssuming every message arrives exactly once
Use one consumer per threadSharing one KafkaConsumer across threads
Keep each batch’s processing well under max.poll.interval.msSlow work that causes repeated rebalances
Close consumers on shutdownKilling the process and waiting for the session timeout
Send bad messages to a dead-letter topicLetting one bad message block a partition forever
Set auto.offset.reset deliberatelySilently skipping old data with the default latest
Monitor consumer lagDiscovering backlogs only when users complain
Run no more consumers than partitions (plus standbys)Expecting extra consumers to add speed

19. Summary

A Kafka consumer reads messages from Kafka topics and processes them. It pulls records in a poll loop, tracks its progress with committed offsets, and works with other consumers in a group so each partition is handled by one member. Committing after processing gives at-least-once delivery; idempotent processing makes repeats harmless. Clean shutdown, sensible poll settings, dead-letter topics and lag monitoring keep consumers reliable, and Spring Boot’s @KafkaListener makes all of this simple to use.

In simple words, Kafka consumers help applications listen to events, process data and react in real time.