Back to Delivery Semantics & Exactly-Once
Q9Question 9 of 10

What are the Spring Kafka AckModes, when do you use manual acknowledgment, and why does commit-after-processing give at-least-once?

Key Concepts
  • AckMode controls when the listener container commits consumer offsets -- the bookmark of how far you have read.
  • BATCH (default) commits once after the whole poll() batch is processed; RECORD commits after each individual record.
  • MANUAL and MANUAL_IMMEDIATE hand control to your code via an injected Acknowledgment object.
  • Commit-after-processing is at-least-once: a crash before the commit causes redelivery, never data loss.
  • Commit-before-processing is at-most-once: a crash after the commit but before work loses the record.
  • At-least-once plus idempotent processing gives effectively-once in practice.
  • Changing AckMode only shrinks or widens the replay window; it never eliminates replays entirely.

Definition

The AckMode is when the offset bookmark moves, and committing only after successful processing is what makes delivery at-least-once.

In Spring Kafka an AckMode controls when the listener container commits consumer offsets.

  • BATCH – the default; commits once after the whole poll() batch is processed.
  • RECORD – commits after each individual record's listener call returns successfully.
  • TIME / COUNT / COUNT_TIME – commits on a time interval, a record count, or whichever comes first; throughput-oriented.
  • MANUAL – you call Acknowledgment.acknowledge() yourself; the commit is queued until the batch completes.
  • MANUAL_IMMEDIATE – you call acknowledge() and the commit happens right away on the consumer thread.
  • At-least-once – the guarantee when committing after processing: a crash before the commit replays records, so nothing is lost but duplicates are possible.

The offset is a bookmark in a ledger that says how many transactions the clerk at National Bank of India has already posted. The AckMode decides when the clerk moves the bookmark forward. BATCH moves it after finishing the whole tray. RECORD moves it after every single posting. MANUAL means the clerk only moves it when they personally confirm the posting is safely in the ledger. If the clerk posts Rajesh's transfer but faints before moving the bookmark, a new clerk starts from the old bookmark and posts it again -- the money is never lost, but it might be posted twice.

Understanding the Concepts

What is an offset commit in Kafka?

An offset is Kafka's record of how far a consumer group has consumed each partition. Committing offset N means you have safely processed everything up to N. If you crash, you restart from the last committed offset.

JAVA
package com.bank.kafka;

/**
 * Conceptual: offset is the bookmark.
 */
public class OffsetConceptDemo {
    public static void main(String[] args) {
        // Partition 0 has records at offsets 0, 1, 2, 3, 4, 5
        // Consumer processed up to offset 3 and committed 3
        // If consumer crashes, it restarts from offset 3
        System.out.println("Last committed offset: 3");
        System.out.println("On restart, consumer reads from offset 3 onward");
    }
}
TEXT
Last committed offset: 3
On restart, consumer reads from offset 3 onward

What is BATCH AckMode (the default)?

BATCH waits until every record returned by one poll() has been processed and then commits the highest offset in one go. It is efficient but a failure midway through a batch can replay earlier records.

JAVA
package com.bank.kafka;

import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

@Component
public class BatchAckListener {
    // Default AckMode is BATCH: commit after entire poll batch
    @KafkaListener(topics = "settlements", groupId = "settlement-group")
    public void onSettlement(String event) {
        System.out.println("Processing: " + event);
        // Container commits after ALL records from this poll are done
    }
}
TEXT
Processing: {"account":"SAV2001","amount":50000}
Processing: {"account":"SAV3001","amount":25000}
# Offset committed once after both records processed

What is RECORD AckMode?

RECORD commits after each individual record. The redelivery window is tighter than BATCH but commit traffic is higher.

JAVA
package com.bank.kafka;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.listener.ContainerProperties;

@Configuration
public class RecordAckConfig {
    @Bean
    public ConcurrentKafkaListenerContainerFactory<String, String> recordAckFactory() {
        ConcurrentKafkaListenerContainerFactory<String, String> factory =
            new ConcurrentKafkaListenerContainerFactory<>();
        factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.RECORD);
        return factory;
    }
}
TEXT
# Record 1 processed -> offset 1 committed
# Record 2 processed -> offset 2 committed
# Crash after record 2 committed: restart from offset 3, no replay

What is MANUAL acknowledgment?

MANUAL and MANUAL_IMMEDIATE put you in charge. Spring injects an Acknowledgment into your listener and you decide when to commit. This is critical when the commit must be tied to a side effect succeeding.

JAVA
package com.bank.kafka;

import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.stereotype.Component;

@Component
public class ManualAckListener {

    @KafkaListener(topics = "settlements", groupId = "settlement-group",
        containerFactory = "manualAckFactory")
    public void onSettlement(String event, Acknowledgment ack) {
        System.out.println("Processing: " + event);
        // Only acknowledge after the DB write succeeds
        settlementRepository.save(event);
        ack.acknowledge();
        System.out.println("Acknowledged after DB write");
    }

    // If DB throws, we do NOT acknowledge -> Kafka redelivers
}
TEXT
Processing: {"account":"SAV2001","amount":50000}
Acknowledged after DB write

Why does commit-after-processing give at-least-once?

The container calls your method, your method does the work, and only then does the offset advance. If the process dies at any point before the commit lands, the broker still has the old offset. On restart, the consumer re-reads from that old offset and processes the records again. Records are never lost but may be delivered more than once.

TEXT
Scenario: crash between processing and commit

1. Consumer reads offset 5: {account: SAV2001, amount: 50000}
2. Listener processes: DB write succeeds
3. CRASH before offset 5 is committed
4. Restart: consumer reads from last committed offset (4)
5. Offset 5 is redelivered and processed AGAIN

Result: SAV2001 settlement processed twice -- at-least-once
Fix: make processing idempotent (primary key = txnId)

How do you make processing idempotent to handle redelivery?

At-least-once plus idempotent processing gives effectively-once. Use a unique key like transaction ID as the primary key in the database so a duplicate write is a harmless no-op.

JAVA
package com.bank.kafka;

import java.math.BigDecimal;

public class IdempotentSettlementService {
    public void settle(String txnId, String accountId, BigDecimal amount) {
        // INSERT ... ON CONFLICT (txn_id) DO NOTHING
        // If this txnId was already settled, the insert is a no-op
        System.out.println("Settling txn=" + txnId + " account=" + accountId
            + " amount=" + amount);
        System.out.println("Idempotent: duplicate insert is a harmless no-op");
    }
}
TEXT
Settling txn=TXN-12345 account=SAV2001 amount=50000
Idempotent: duplicate insert is a harmless no-op

What is the difference between commit-before vs commit-after?

| Timing | Guarantee | Risk |
|---|---|---|
| Commit before processing | At-most-once | Crash after commit loses the record |
| Commit after processing | At-least-once | Crash before commit causes redelivery |
| Kafka transactions | Exactly-once (within Kafka) | Heavier, needs transactional producer |

JAVA
package com.bank.kafka;

public class CommitTimingDemo {
    public static void main(String[] args) {
        // At-most-once: commit first, then process
        System.out.println("At-most-once: offset committed -> process -> crash = record LOST");

        // At-least-once: process first, then commit
        System.out.println("At-least-once: process -> offset committed -> crash before commit = REDELIVERY");

        // Spring Kafka default (BATCH) is at-least-once
    }
}
TEXT
At-most-once: offset committed -> process -> crash = record LOST
At-least-once: process -> offset committed -> crash before commit = REDELIVERY

When should you use MANUAL vs BATCH?

Use MANUAL when the commit must be tied to a side effect: only acknowledge after the DB write and the downstream publish both succeed. Use BATCH (default) for simpler cases where the listener method itself is the complete unit of work.

JAVA
package com.bank.kafka;

import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.stereotype.Component;

@Component
public class ComplexAckListener {

    @KafkaListener(topics = "transfers", groupId = "transfer-group",
        containerFactory = "manualAckFactory")
    public void onTransfer(String event, Acknowledgment ack) {
        try {
            transferRepository.save(event);     // DB write
            notificationService.notify(event);  // downstream call
            ack.acknowledge();                  // commit only after BOTH succeed
        } catch (Exception e) {
            // Do NOT acknowledge -- Kafka will redeliver
            System.err.println("Processing failed, will be redelivered: " + e.getMessage());
        }
    }
}
TEXT
# Success path: DB write + notify + acknowledge -> offset advances
# Failure path: exception -> no acknowledge -> offset stays -> redelivery

What should a senior engineer know?

Changing AckMode only shrinks or widens the replay window, it never eliminates replays. RECORD gives the tightest window; BATCH is efficient; MANUAL gives explicit control. At-least-once is the realistic guarantee for the consume-and-act side. The way to make it safe is idempotent processing, using a dedup key so a replay is harmless.

BASH
# Summary of AckModes
# BATCH (default): commit after poll batch -- efficient, larger replay window
# RECORD:          commit per record -- tighter replay, more commit traffic
# TIME:            commit on interval -- throughput-oriented
# COUNT:           commit after N records -- throughput-oriented
# MANUAL:          you call ack.acknowledge() -- commit queued to batch end
# MANUAL_IMMEDIATE: you call ack.acknowledge() -- commit right now

Visual Overview

DIAGRAM
  Spring Kafka AckMode Timeline

  BATCH (default):
  poll() returns [rec1, rec2, rec3]
  │
  ├── process rec1 ✓
  ├── process rec2 ✓
  ├── process rec3 ✓
  └── commit offset 3 ✓   ← one commit for whole batch

  RECORD:
  poll() returns [rec1, rec2, rec3]
  │
  ├── process rec1 ✓ → commit offset 1 ✓
  ├── process rec2 ✓ → commit offset 2 ✓
  └── process rec3 ✓ → commit offset 3 ✓

  MANUAL:
  poll() returns [rec1, rec2, rec3]
  │
  ├── process rec1 → DB write ✓ → ack.acknowledge()
  ├── process rec2 → DB write ✓ → ack.acknowledge()
  └── process rec3 → DB write FAIL → no ack → REDELIVERY

  At-Least-Once Guarantee:
  ┌──────────┐    ┌──────────┐    ┌──────────┐
  │ Process   │───→│ Commit   │    │ Crash    │
  │ record    │    │ offset   │    │ here?    │
  └──────────┘    └──────────┘    └──────────┘
       ▲                                │
       └── restart from old offset ─────┘
       Record processed again (at-least-once)

Key Takeaway

In Spring Kafka the AckMode decides exactly when the listener container commits consumer offsets, and the offset is Kafka's bookmark of how far a consumer group has read. BATCH commits once after the whole poll batch and is the default. RECORD commits after every individual record. MANUAL and MANUAL_IMMEDIATE hand the decision to your code through an injected Acknowledgment object.

The reason committing after processing gives at-least-once is the order of operations: the container runs your method, the method does the work, and only then does the offset move. If the process dies before the commit, the consumer restarts from the old offset and processes the records again. Nothing is ever lost, but records can be delivered more than once.

In the settlement service at National Bank of India, the listener wrote Rajesh's settlement to the database and only then called acknowledge. If the database was briefly unavailable, the method threw, the offset did not advance, and Kafka redelivered for a clean retry. The settlements table had the transaction ID as its primary key with an insert-on-conflict-do-nothing, so a redelivery was a harmless no-op and Rajesh's money was never posted twice.

Remember one sentence: at-least-once delivery plus idempotent processing is how you achieve effectively-once in a bank, and the AckMode only controls how wide the replay window is.

---

Share
Use ← → arrow keys to navigate