Definition
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.
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");
}
}
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.
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
}
}
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.
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;
}
}
# 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.
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
}
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.
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.
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");
}
}
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 |
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
}
}
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.
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());
}
}
}
# 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.
# 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
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.
---