Skip to content
Apache Kafka kf spring 4 min read

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

AckModeWhen the offset is committedAcknowledgment needed?
RECORDAfter the listener returns for each recordNo
BATCHAfter the listener processes the whole poll() batch (default)No
TIMEWhen ackTime elapses since the last commitNo
COUNTAfter ackCount records have been processedNo
COUNT_TIMEWhen either ackTime or ackCount is reachedNo
MANUALWhen you call acknowledge(); queued and committed on the next pollYes
MANUAL_IMMEDIATEWhen you call acknowledge(); committed synchronously right awayYes

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 MANUAL and MANUAL_IMMEDIATE is timing. MANUAL defers the commit to the next consumer poll cycle (slightly more efficient, batches commits), while MANUAL_IMMEDIATE commits on the consumer thread the moment you call it (lower throughput, tighter durability). Use MANUAL_IMMEDIATE when 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_IMMEDIATE when durability matters more than raw throughput; use MANUAL to 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 the Acknowledgment is bound to that record.
  • Pair manual ack with a DefaultErrorHandler and backoff so failed records are retried (and eventually dead-lettered) instead of blocking the partition forever.
  • Keep enable.auto.commit=false explicit in config to make the offset-commit strategy obvious to future maintainers.
Last updated June 1, 2026
Was this helpful?