Manual Acknowledgment
By default Spring for Apache Kafka commits offsets for you, which is convenient but quietly dangerous: an offset can be committed before your business logic actually finishes, so a crash mid-processing silently loses the record. Manual acknowledgment hands offset control back to your code — you call acknowledge() only after a record is fully and successfully processed. This is the foundation of reliable at-least-once delivery, and it is the right default for any listener that writes to a database, calls a downstream service, or otherwise has side effects you cannot afford to drop.
How offset commits work
A Kafka consumer tracks its position per partition with an offset. Committing an offset tells the broker “I have processed everything up to here.” If the consumer restarts, it resumes from the last committed offset. The question that decides your delivery guarantee is simply: when does the commit happen relative to your processing?
Spring controls this with the listener container’s AckMode. You configure it on the ContainerProperties of your ConcurrentKafkaListenerContainerFactory. Crucially, Spring disables Kafka’s built-in auto-commit (enable.auto.commit=false) automatically whenever the container manages acknowledgment, which is almost always.
AckMode options
| AckMode | When the offset is committed | Acknowledgment needed? |
|---|---|---|
RECORD | After the listener returns for each record | No |
BATCH | After the listener processes the whole poll() batch (default) | No |
TIME | When ackTime elapses since the last commit | No |
COUNT | After ackCount records have been processed | No |
COUNT_TIME | When either ackTime or ackCount is reached | No |
MANUAL | When you call acknowledge(); queued and committed on the next poll | Yes |
MANUAL_IMMEDIATE | When you call acknowledge(); committed synchronously right away | Yes |
BATCH and RECORD are automatic — Spring commits for you based on whether the listener method threw an exception. The two MANUAL modes are different: the container will not commit until you explicitly ask it to, giving you precise, per-record control.
The difference between
MANUALandMANUAL_IMMEDIATEis timing.MANUALdefers the commit to the next consumer poll cycle (slightly more efficient, batches commits), whileMANUAL_IMMEDIATEcommits on the consumer thread the moment you call it (lower throughput, tighter durability). UseMANUAL_IMMEDIATEwhen you need the offset persisted before doing anything else.
Configuring the container factory
To enable manual acks, set the ack mode on the factory’s container properties. Set the consumer’s enable.auto.commit to false as well — Spring does this for you under manual modes, but being explicit documents intent.
@Configuration
@EnableKafka
public class KafkaConsumerConfig {
@Bean
public ConsumerFactory<String, OrderEvent> consumerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "orders");
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
return new DefaultKafkaConsumerFactory<>(
props,
new StringDeserializer(),
new JsonDeserializer<>(OrderEvent.class));
}
@Bean
public ConcurrentKafkaListenerContainerFactory<String, OrderEvent> kafkaListenerContainerFactory() {
var factory = new ConcurrentKafkaListenerContainerFactory<String, OrderEvent>();
factory.setConsumerFactory(consumerFactory());
factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
return factory;
}
}
The DTO is a simple record:
public record OrderEvent(String orderId, String customerId, BigDecimal amount) {}
Acknowledging in the listener
Once a manual ack mode is active, inject the Acknowledgment object as a method parameter. Spring populates it for you. Call acknowledge() last, only after every side effect has succeeded — if processing throws before that call, the offset stays uncommitted and the record is redelivered after the configured error handling.
@Component
public class OrderListener {
private static final Logger log = LoggerFactory.getLogger(OrderListener.class);
private final OrderService orderService;
public OrderListener(OrderService orderService) {
this.orderService = orderService;
}
@KafkaListener(topics = "orders", containerFactory = "kafkaListenerContainerFactory")
public void onOrder(OrderEvent event, Acknowledgment ack) {
log.info("Processing order {}", event.orderId());
orderService.persist(event); // side effect must succeed first
ack.acknowledge(); // commit only after success
log.info("Committed offset for order {}", event.orderId());
}
}
Output:
INFO c.e.kafka.OrderListener - Processing order A-1001
INFO c.e.kafka.OrderListener - Committed offset for order A-1001
INFO c.e.kafka.OrderListener - Processing order A-1002
INFO c.e.kafka.OrderListener - Committed offset for order A-1002
If orderService.persist() throws, acknowledge() is never reached, the container’s error handler kicks in, and the record is reprocessed from the same offset on the next attempt.
Acknowledging in batch listeners
With batch listeners you receive a List plus a single Acknowledgment covering the whole batch. Call acknowledge() once after the entire batch is durable. For partial commits you can use acknowledge(int index) (Spring Kafka 3.x) to ack up to a specific record in the batch.
@KafkaListener(topics = "orders", containerFactory = "batchKafkaListenerContainerFactory")
public void onBatch(List<OrderEvent> events, Acknowledgment ack) {
orderService.persistAll(events);
ack.acknowledge();
}
At-least-once and idempotency
Manual ack gives you at-least-once, not exactly-once. A crash between the side effect and acknowledge() means the record is redelivered and processed again. Design consumers to be idempotent — use the event key, a unique business id, or an upsert so that reprocessing the same record is harmless. For true exactly-once across produce-and-consume, reach for Kafka transactions instead.
Best practices
- Prefer
MANUAL_IMMEDIATEwhen durability matters more than raw throughput; useMANUALto batch commits when throughput is the priority. - Always call
acknowledge()as the last statement after all side effects have committed — never at the top of the method. - Make listeners idempotent; manual ack guarantees at-least-once, so duplicates are normal during failures.
- Never call
acknowledge()from a separate thread or after the method returns — the consumer is single-threaded per partition and theAcknowledgmentis bound to that record. - Pair manual ack with a
DefaultErrorHandlerand backoff so failed records are retried (and eventually dead-lettered) instead of blocking the partition forever. - Keep
enable.auto.commit=falseexplicit in config to make the offset-commit strategy obvious to future maintainers.