Skip to content
Apache Kafka kf spring 4 min read

Batch Listeners

By default a @KafkaListener is invoked once per record, even though the underlying consumer fetches many records in a single poll(). Batch mode flips that: Spring hands your listener the entire poll result as a List, letting you amortize expensive per-call costs — a database round trip, an HTTP request, an index commit — across hundreds of records at once. For high-volume pipelines that write to a store supporting bulk operations, batch listeners are often the single biggest throughput win you can make on the consumer side.

Enabling batch mode

Batch mode is a property of the listener container, not the consumer. You turn it on either globally through application.yml or per-factory when you declare your own @Bean. The records returned per invocation are still capped by max-poll-records, so that property effectively becomes your batch size.

spring:
  kafka:
    bootstrap-servers: localhost:9092
    consumer:
      group-id: orders-service
      auto-offset-reset: earliest
      max-poll-records: 500
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      properties:
        spring.json.trusted.packages: "com.devcraftly.orders.events"
        spring.json.value.default.type: "com.devcraftly.orders.events.OrderPlaced"
    listener:
      type: batch
      ack-mode: batch

Setting spring.kafka.listener.type=batch switches the auto-configured factory to batch delivery. When you build the factory yourself, call setBatchListener(true):

import org.springframework.boot.autoconfigure.kafka.KafkaProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.core.ConsumerFactory;

@Configuration
public class KafkaBatchConfig {

    @Bean
    public ConcurrentKafkaListenerContainerFactory<String, OrderPlaced> batchFactory(
            ConsumerFactory<String, OrderPlaced> consumerFactory) {
        var factory = new ConcurrentKafkaListenerContainerFactory<String, OrderPlaced>();
        factory.setConsumerFactory(consumerFactory);
        factory.setBatchListener(true);
        return factory;
    }
}

A batch listener method

With batch mode enabled, your listener parameter becomes a List. The most ergonomic form receives a List of your deserialized event type; Spring deserializes every record in the poll and passes them as one list.

package com.devcraftly.orders.events;

public record OrderPlaced(String orderId, String customerId, long amountCents) {}
import java.util.List;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

@Component
public class OrderBatchListener {

    private final OrderRepository repository;

    public OrderBatchListener(OrderRepository repository) {
        this.repository = repository;
    }

    @KafkaListener(
            topics = "orders",
            groupId = "orders-service",
            containerFactory = "batchFactory")
    public void onOrders(List<OrderPlaced> orders) {
        // one bulk write for the whole poll instead of 500 single inserts
        repository.saveAll(orders);
    }
}

Output:

Batch received: 500 records, persisted in 1 saveAll() call

Accessing metadata with ConsumerRecord

When you need offsets, partitions, keys, or headers, accept a List<ConsumerRecord<K, V>> instead. You can also add Acknowledgment (for manual ack) and a Consumer<?, ?> parameter for advanced control.

import java.util.List;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.stereotype.Component;

@Component
public class OrderRecordBatchListener {

    @KafkaListener(topics = "orders", containerFactory = "batchFactory")
    public void onOrders(List<ConsumerRecord<String, OrderPlaced>> records,
                         Acknowledgment ack) {
        for (ConsumerRecord<String, OrderPlaced> record : records) {
            System.out.printf("partition=%d offset=%d order=%s%n",
                    record.partition(), record.offset(), record.value().orderId());
        }
        ack.acknowledge(); // commit the whole batch at once
    }
}

Batch error handling

The default record-level error handler does not apply to batch listeners. If your method throws, the entire batch is reprocessed — Spring cannot know which record failed unless you tell it. The recommended handler is DefaultErrorHandler combined with a thrown BatchListenerFailedException, which carries the index (or the offending record) so the container can commit the good records and route only the failed one for retry or to a dead-letter topic.

import org.springframework.kafka.listener.BatchListenerFailedException;

@KafkaListener(topics = "orders", containerFactory = "batchFactory")
public void onOrders(List<ConsumerRecord<String, OrderPlaced>> records) {
    for (int i = 0; i < records.size(); i++) {
        try {
            repository.save(records.get(i).value());
        } catch (Exception ex) {
            // tells DefaultErrorHandler exactly which record failed
            throw new BatchListenerFailedException("save failed", ex, i);
        }
    }
}

Register a DefaultErrorHandler on the factory (optionally with a DeadLetterPublishingRecoverer) so it understands the index and skips reprocessing already-committed records:

factory.setCommonErrorHandler(new org.springframework.kafka.listener.DefaultErrorHandler());

Warning: Without BatchListenerFailedException, any exception causes the whole batch to be retried from the first record, which can replay successfully processed records and create duplicates. Make batch processing idempotent or use the indexed exception.

When batching helps — and when it does not

ScenarioPer-record listenerBatch listener
Bulk DB insert / saveAllOne round trip per recordOne round trip per batch
External call per recordN HTTP callsCan group into bulk endpoints
Independent, cheap workSimple, fine-grainedLittle benefit, more complexity
Strict per-record retry/DLTBuilt-in, easyNeeds BatchListenerFailedException
Low, bursty trafficPredictable latencyAdds latency waiting to fill a poll

Batching shines when the downstream system is faster per batch than per record — relational bulk inserts, Elasticsearch _bulk, S3 multi-object writes, or aggregations. It costs you per-record granularity in error handling and adds tail latency, since a record waits for the rest of its poll. For latency-sensitive, low-throughput topics, stick with single-record listeners.

Best Practices

  • Treat max-poll-records as your batch size and tune it so a full batch processes well within max.poll.interval.ms, avoiding rebalances.
  • Use bulk operations (saveAll, _bulk, multi-row INSERT) inside the listener — a batch listener that loops single inserts gains nothing.
  • Throw BatchListenerFailedException with the failed index so DefaultErrorHandler commits good records and isolates the bad one.
  • Make batch processing idempotent; on retry the whole batch (or a suffix of it) may be redelivered.
  • Use ack-mode: batch (or MANUAL with one ack.acknowledge() per batch) so the entire poll commits atomically after success.
  • Keep a dedicated batch factory and containerFactory reference rather than flipping the global default, so latency-sensitive listeners stay per-record.
  • Benchmark before and after — batching only pays off when the downstream cost is dominated by per-call overhead.
Last updated June 1, 2026
Was this helpful?