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
| Scenario | Per-record listener | Batch listener |
|---|---|---|
Bulk DB insert / saveAll | One round trip per record | One round trip per batch |
| External call per record | N HTTP calls | Can group into bulk endpoints |
| Independent, cheap work | Simple, fine-grained | Little benefit, more complexity |
| Strict per-record retry/DLT | Built-in, easy | Needs BatchListenerFailedException |
| Low, bursty traffic | Predictable latency | Adds 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-recordsas your batch size and tune it so a full batch processes well withinmax.poll.interval.ms, avoiding rebalances. - Use bulk operations (
saveAll,_bulk, multi-rowINSERT) inside the listener — a batch listener that loops single inserts gains nothing. - Throw
BatchListenerFailedExceptionwith the failed index soDefaultErrorHandlercommits 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(orMANUALwith oneack.acknowledge()per batch) so the entire poll commits atomically after success. - Keep a dedicated batch factory and
containerFactoryreference 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.