Manual Offset Commits: Taking Control of What Your Consumer Has Truly Processed
A clear, practical guide to how Apache Kafka consumers remember their place in a stream, why letting the client decide when to save that place can quietly lose or duplicate data, and how committing offsets yourself gives you honest delivery guarantees.
Imagine you are reading a very long book, and you use a bookmark to save your place. Every time you close the book, the bookmark tells you where to continue tomorrow. Now imagine a strange bookmark that moves forward by itself every five minutes, whether or not you actually finished reading those pages. If you fall asleep at page 90 but the bookmark jumped to page 120, tomorrow you will start at page 120 and never know you missed thirty pages. That is exactly the risk that manual offset commits are designed to remove. In Kafka, the bookmark is called an offset, and deciding when to move it is one of the most important choices a consumer application makes.
This tutorial walks through the idea from the ground up. We begin with what an offset is, move on to what it means to commit one, look at why the automatic approach is risky, and then study the manual approach in depth: how it works inside the cluster, how it behaves during failures and rebalances, and how to use it safely in real production systems. No prior knowledge is assumed, and every new term is explained with a simple comparison from everyday life.
1The Building Blocks: Topics, Partitions and Offsets
Before we can talk about saving a position, we need to understand what a position actually is.
Apache Kafka is a system for moving streams of events between applications. An event might be a customer placing an order, a sensor reporting a temperature, or a user clicking a button. Events are grouped into named categories called topics. You can think of a topic as a labelled shelf in a very large library, where every book on that shelf is about the same subject.
A single topic can be very large, so Kafka splits it into smaller pieces called partitions. Each partition is an ordered, append-only log. New events are always added to the end, and once written, an event is never changed in place. Because each partition is its own separate log, Kafka can spread the partitions across many servers, which is how it handles enormous amounts of data.
What exactly is an offset?
Every event inside a partition receives a sequence number, starting at zero and increasing by one for each new event. That number is the offset. It is unique only within its partition, so the first event of partition 0 and the first event of partition 1 both have offset 0. To point at any single event in Kafka, you need three pieces of information: the topic, the partition, and the offset.
A partition is like a long roll of receipt paper coming out of a till. Each receipt is numbered in order. The offset is simply the receipt number. If someone says “I processed up to receipt 41,” you know exactly where to continue: receipt 42.
Topic
A named stream of related events, such as “orders” or “payments”.
Partition
One ordered log inside a topic. Ordering is guaranteed only within a single partition.
Offset
The position number of an event inside one partition, counting up from zero.
Consumer
An application that reads events from one or more partitions.
Consumer Group
A team of consumers sharing a name, splitting the partitions of a topic among themselves.
Broker
A Kafka server that stores partitions and serves reads and writes.
Why consumer groups matter here
Consumers rarely work alone. A consumer group is a set of consumers that share the same group name and cooperate to read a topic. Kafka gives each partition to exactly one consumer in the group at a time, so the work is divided without two members reading the same partition simultaneously. If a topic has six partitions and the group has three consumers, each consumer typically reads two partitions.
This is important because the saved position is remembered per group, not per individual consumer. If one consumer crashes, another member of the same group takes over its partitions and continues from the group’s last saved position. That handover only works if the position was saved correctly, which brings us to committing.
2What Does It Mean to Commit an Offset?
Committing is the act of telling Kafka, “I am done with everything before this point.”
When a consumer reads events, it keeps a position in memory that moves forward with every batch it receives. This in-memory position is private to the running process. If the process crashes, restarts, or is replaced by another machine, that memory is gone. A new consumer would have no idea where to begin.
To solve this, Kafka lets consumers save their position to the cluster itself. This save operation is called committing an offset. The saved value is called the committed offset. When any consumer in the group later needs to know where to start for a given partition, it asks the cluster for the committed offset and resumes from there.
The next-message rule
Here is a detail that trips up many beginners. The committed offset is not the offset of the last event you processed. It is the offset of the next event you want to read. If you finished processing the event at offset 41, the value you commit is 42. Think of it as the bookmark pointing to the first page you have not yet read, not the last page you finished.
Committing the offset of the last processed event instead of that offset plus one causes the same event to be delivered again after every restart. It is a small off-by-one error with very visible duplicate effects.
Where is the committed offset stored?
Kafka stores committed offsets inside itself, in a special internal topic named __consumer_offsets. Each commit is written as a small record whose key is the combination of group name, topic and partition, and whose value is the committed offset along with a little metadata such as a timestamp. Because it is an ordinary replicated Kafka log, the saved positions enjoy the same durability as your data. Older versions of Kafka used ZooKeeper for this purpose, but modern versions have used the internal topic for many years.
Because the record key is group plus topic plus partition, only the newest commit matters for each combination. Kafka compacts this topic in the background so older commits for the same key are eventually cleaned away.
3Automatic Commits and Why They Fall Short
The default behaviour is convenient, but convenience hides a gap between what was read and what was truly finished.
By default, a Kafka consumer commits offsets on its own. A setting called enable.auto.commit is turned on, and another setting, auto.commit.interval.ms, defaults to five seconds. Each time the application asks for more events, the client checks whether the interval has passed, and if so, it quietly commits the positions returned by the previous request.
For a quick experiment or a low-stakes pipeline, this is fine. But there is a subtle problem. The client has no idea what your application did with the events. It only knows that it handed them to you. Whether you wrote them to a database, called a payment service, or crashed halfway through is invisible to the client.
Two ways automatic commits can hurt
The first problem is data loss. Suppose the consumer receives 500 events and the auto-commit timer fires when you ask for the next batch, committing the whole batch. If your application crashes while it is still in the middle of processing that batch, the replacement consumer starts after the committed position. The events you never finished are skipped forever.
The second problem is duplicate processing. Suppose you have processed 400 of 500 events, but the timer has not fired yet, and then the application crashes. The replacement consumer starts from the last commit, which is before this batch. All 400 finished events are processed a second time. Without safeguards, a customer might be charged twice or receive the same email twice.
Automatic commits are like a warehouse worker whose supervisor ticks the “delivered” box on the paperwork every five minutes, based on the clock rather than on whether the parcels reached the doors. Usually it matches reality. On the day the van breaks down, the paperwork says everything arrived when it did not.
sequenceDiagram
participant K as Kafka Partition
participant C as Consumer
participant B as Auto Commit Timer
K->>C: Deliver batch of events 100 to 199
B->>K: Timer fires, commit position 200
C->>C: Still processing event 130
C--xC: Application crashes
Note over K,C: New consumer resumes at 200
Note over K,C: Events 130 to 199 are lost
Many people assume automatic commits give at-least-once delivery. They often do in calm conditions, but nothing guarantees it. Because the commit is driven by a timer and not by your processing result, loss and duplication are both possible.
4So, What Are Manual Offset Commits?
Manual offset commits hand the decision of “when is this really done” back to your application code.
With manual offset commits, you switch off automatic committing and take responsibility for telling Kafka when a position should be saved. Your code reads a batch of events, does the real work, and only when the work has succeeded does it call the commit operation. The consumer client never commits on its own behalf. The saved position therefore always reflects what your application has decided is complete.
The idea is simple, but its consequences are large. You now choose the moment of the commit, the granularity of the commit, whether the commit blocks or runs in the background, and what happens when a commit fails. Each choice trades speed against safety, and understanding those trade-offs is the heart of this tutorial.
How to switch it on
Turning on manual control needs one configuration change: set enable.auto.commit to false. From that moment the client stops committing by itself. Your application must then call one of the commit operations provided by the consumer library. Frameworks built on top of Kafka usually expose this as an acknowledgement mode, where your handler explicitly acknowledges each record or batch once it is finished.
When you should strongly consider manual commits
Whenever losing an event or processing it twice has a real cost: money movement, inventory changes, legal records, customer notifications, or writes into a database that must stay consistent with the stream.
When automatic commits may still be acceptable
Throw-away metrics, cache warm-up, or debug tooling where a few missed or repeated events change nothing important, and where simplicity is more valuable than precision.
| Aspect | Automatic Commit | Manual Commit |
|---|---|---|
| Who decides when to save | A timer inside the client | Your application logic |
| Link to processing result | None | Direct, commit follows success |
| Loss risk | Present | Avoidable with correct ordering |
| Duplicate risk | Present | Reduced, and manageable with idempotent work |
| Code effort | Almost none | Moderate, needs careful design |
| Best for | Low-stakes streams | Business-critical streams |
5Synchronous versus Asynchronous Commits
Once you control the commit, the next question is whether your code should wait for the answer.
The Kafka consumer offers two flavours of manual commit. They save the same kind of information but behave very differently when you call them, and each has situations where it shines.
Synchronous commit
A synchronous commit sends the request to the cluster and waits until the cluster answers. If the answer is a temporary problem, such as a brief network glitch or a leadership change, the client retries automatically until the commit succeeds or a permanent problem occurs. Only then does control return to your code. If something unrecoverable happens, an exception is raised so that you know for certain the commit did not succeed.
The strength of this mode is certainty. When the call returns quietly, the position is saved. The weakness is speed. Every commit pauses your loop for a network round trip, which can noticeably reduce throughput if you commit very frequently.
Asynchronous commit
An asynchronous commit sends the request and returns immediately, letting your loop carry on reading and processing while the commit travels in the background. You may attach a callback that runs later to tell you whether the commit worked. This keeps throughput high because the loop is never stalled by the network.
The catch is that the client deliberately does not retry a failed asynchronous commit. The reason is ordering. Imagine commit A for position 100 fails and is retried, while commit B for position 200 has already succeeded. If A’s retry lands afterwards, it would overwrite the newer position with an older one, and after a restart you would reprocess events 100 to 199 needlessly. Skipping the retry avoids that; the next commit will carry a newer position anyway.
Blocking and certain
Waits for the answer, retries temporary failures, and raises an exception on permanent ones.
Fast and fire-and-forget
Returns instantly, reports the result to an optional callback, and never retries on its own.
A synchronous commit is a registered letter where you stand at the counter until the clerk stamps your receipt. An asynchronous commit is dropping a normal letter in a postbox. It is quicker, but if it gets lost, you only learn about it later, if at all.
The popular combined approach
Many production consumers use both. During the normal loop they commit asynchronously for speed, accepting that an occasional commit may fail because a later one will succeed. Right before the consumer shuts down, or just before it gives up its partitions, they perform one final synchronous commit so that the last position is guaranteed to be saved. This gives near-maximum throughput in steady state and a firm guarantee at the moments where it matters most.
| Question | Synchronous | Asynchronous |
|---|---|---|
| Blocks the loop? | Yes | No |
| Automatic retry? | Yes, for temporary errors | No |
| Knows result immediately? | Yes | Only through a callback |
| Throughput impact | Higher cost per commit | Minimal |
| Best moment to use | Shutdown, rebalance, critical checkpoints | Steady-state processing |
6Choosing the Commit Granularity
You decide not only when to commit, but how much progress each commit records.
Granularity means the size of the step between two commits. Kafka lets you commit at several levels, and the right level depends on how expensive a duplicate is compared with how expensive a commit is.
Commit after each batch
The most common pattern is to commit once after your application has finished an entire batch returned by the consumer. The client provides a simple form of commit that saves the latest positions of every partition it has handed you so far. It is cheap because there is one request per batch, and it is easy to reason about. The cost is that a crash in the middle of a batch causes the whole batch to be redelivered.
Commit after each event
At the other extreme, you can commit after every single event. This minimises how much work is repeated after a failure, but it multiplies the number of commit requests dramatically. At high volumes this places noticeable load on both your consumer and the brokers hosting the internal offsets topic. It is appropriate only when each event is very expensive to reprocess and the volume is modest.
Commit specific partition positions
The consumer also lets you pass an explicit map of partitions to offsets. This gives fine control. You can commit only the partitions you have completely finished, while others are still being worked on, or commit progress after every fixed number of events. It is especially useful when work is handed to a pool of worker threads that finish at different times, since you can commit only the contiguous prefix of work that is fully done.
Per event
Smallest redo window, highest commit traffic. Use for rare, costly events.
Per N events
A balanced compromise. Commit after every few hundred events or every few seconds of work.
Per batch
The default sensible choice for most applications. One commit per poll result.
Per partition with explicit offsets
Highest control. Ideal when worker threads finish out of order and you must commit only safe positions.
Ask yourself: “If this crashes right now, how much work am I willing to repeat?” The answer tells you how often to commit. If repeating is cheap or harmless, commit less often. If repeating is painful, commit more often or make the work safe to repeat.
If several threads process events from one partition, never commit the highest finished offset while a lower one is still running. A crash then skips the unfinished lower event. Commit only up to the point where every earlier event is confirmed complete.
7Delivery Semantics: What the Guarantees Really Mean
The order of “process” and “commit” decides which promise your system can make.
Delivery semantics describe how many times an event affects your system when failures happen. There are three well-known levels. Manual commits are the tool that lets you choose between them deliberately, instead of accidentally.
At-most-once
If you commit first and process afterwards, then a crash between the two steps means the event is marked done but was never handled. The event is delivered at most one time and might be delivered zero times. This is the loss-prone mode. It can be acceptable for very high-volume telemetry where a missing reading does not matter.
At-least-once
If you process first and commit afterwards, then a crash between the two steps means the work was done but the position was not saved. After restart, the event arrives again. The event is delivered at least one time and possibly more. This is the most popular choice for business systems because nothing is lost. The price is that your processing must cope with duplicates.
Exactly-once
Exactly-once means the effect of each event appears once, even when retries happen. Kafka offers this inside its own world through transactions: a processor can write its output events and commit its input offsets in one atomic transaction, so either both happen or neither does. Consumers reading that output must be configured to see only committed transactional data. Outside Kafka, for example when writing to a relational database, you reach the same result by combining at-least-once delivery with idempotent handling or by storing the offset in the same transaction as the business data.
flowchart TD
A["Read batch from Kafka"] --> B{"Order of steps"}
B -->|"Commit first, then process"| C["At-most-once"]
B -->|"Process first, then commit"| D["At-least-once"]
B -->|"Process and commit in one atomic step"| E["Exactly-once effect"]
C --> F["Risk: lost events"]
D --> G["Risk: duplicate events"]
E --> H["Needs transactions or idempotency"]
| Guarantee | Order of Steps | Failure Outcome | Typical Use |
|---|---|---|---|
| At-most-once | Commit, then process | Event may be lost | Non-critical telemetry |
| At-least-once | Process, then commit | Event may repeat | Most business pipelines |
| Exactly-once effect | Atomic process and commit, or idempotent design | No visible loss or repeat | Payments, ledgers, inventory |
Think of paying a restaurant bill. At-most-once is walking out before the waiter writes anything down: you might not pay at all. At-least-once is the waiter writing the bill and you paying, but the receipt getting lost, so you are asked to pay again. Exactly-once is paying with a card machine that records the sale and takes the money in one indivisible action.
8Under the Hood: How a Commit Travels Through the Cluster
A commit is not magic. It is a small request handled by a specific broker with a specific job.
Each consumer group is managed by one broker acting as its group coordinator. The coordinator is chosen by taking the group name, mapping it to one partition of the internal offsets topic, and using the broker that leads that partition. The same broker therefore handles both the membership of the group and the storage of its committed offsets.
Your code calls commit
The client gathers the partitions and offsets to save, plus the group’s current membership generation, a number that identifies the current version of the group.
The request reaches the coordinator
The client first finds the coordinator broker, then sends the offset commit request directly to it.
The coordinator validates the sender
It checks that the consumer is still a member of the group, that the generation is current, and that the partitions are truly assigned to it. A stale member is rejected.
The commit is written to the offsets topic
The coordinator appends the record to the internal log and waits for it to be replicated to the required in-sync copies, like any durable Kafka write.
The acknowledgement returns
The coordinator caches the new position in memory for fast lookups and answers the client. A synchronous caller now continues; an asynchronous caller’s callback fires.
sequenceDiagram
participant App as Application Code
participant Cl as Consumer Client
participant Co as Group Coordinator Broker
participant Lg as Offsets Topic
App->>Cl: Finished batch, request commit
Cl->>Co: Offset commit with group and generation
Co->>Co: Validate membership and assignment
Co->>Lg: Append commit record
Lg-->>Co: Replicated and durable
Co-->>Cl: Success response
Cl-->>App: Return or run callback
What happens when a consumer starts
When a consumer joins a group and receives its partitions, it asks the coordinator for the committed offset of each one. If a value exists, reading resumes there. If none exists, for example because the group is brand new or its saved offsets have expired, a setting named auto.offset.reset decides what to do. Its choices are to start from the earliest available event, start from the latest, or raise an error. This choice has serious consequences, which we return to in the best-practices chapter.
Offset expiry
Committed offsets do not live forever for groups that go idle. Once a group has had no active members for a configured retention period, which defaults to seven days in recent versions, the cluster removes its saved positions. A consumer that returns after a long holiday shutdown may find its bookmark gone and fall back to the reset policy, causing either a huge replay or a silent skip to the newest data.
For seasonal or rarely run consumers, check the offset retention period against the longest gap between runs, and raise it if needed. Also decide the reset policy consciously, never by leaving the default and hoping.
9The Poll Loop and Its Heartbeat Rules
Committing correctly is tied closely to how the consumer proves it is still alive.
A consumer application is built around a loop. In each turn it asks Kafka for a batch of events, processes them, and asks again. That request for events is called polling, and it is more than a data fetch. Polling is also how the client signals that the application is healthy and still making progress.
Three settings govern this. The maximum number of events returned per poll defaults to 500. The maximum allowed time between two polls, called the poll interval, defaults to five minutes. A separate background heartbeat thread tells the coordinator every few seconds that the process is alive, and if no heartbeat arrives within the session timeout, the member is declared dead.
Max poll records
Caps how many events one poll returns. Smaller batches finish faster and shrink the redo window.
Max poll interval
The longest permitted gap between polls. Exceed it and the consumer is removed from the group.
Session timeout
How long the coordinator waits without a heartbeat before assuming the member has failed.
Heartbeat interval
How often the background thread reports that the consumer is still alive.
Why this matters for manual commits
With manual commits, your processing time sits between polls. If a batch takes longer than the poll interval to process, the coordinator concludes the consumer is stuck, removes it, and gives its partitions to someone else. When your slow consumer finally tries to commit, its commit is refused because it no longer owns those partitions. The events it processed will be handled again by the new owner, producing duplicates.
Raising the poll interval to a huge number to hide slow processing delays the detection of genuinely stuck consumers. A better fix is to reduce the batch size, speed up the work, or pause fetching while long work finishes.
Polling is like a lifeguard checking in with the head lifeguard every few minutes. If the check-in does not happen, the head assumes something went wrong and sends a replacement. If the original lifeguard was merely busy rescuing someone, the replacement now duplicates the effort.
10Rebalances and Commits
The most delicate moment for any manual commit is when the group reshuffles its partitions.
A rebalance is the process by which the group redistributes partitions among its members. It happens when a consumer joins, a consumer leaves or is removed, or the number of partitions changes. During a rebalance, partitions are taken away from some members and given to others.
This is where manual commits earn their keep. Before a partition is taken away, the current owner has a brief opportunity to save its progress. The consumer library lets you register a listener that is notified just before partitions are revoked and just after new ones are assigned. In the revocation notification, a well-behaved application finishes or abandons the in-flight work for those partitions and commits a final synchronous offset. The next owner then starts exactly where the previous one stopped, with almost no duplication.
flowchart LR
A["Consumer A owns partitions 0 and 1"] --> B["Consumer B joins the group"]
B --> C["Rebalance begins"]
C --> D["Consumer A notified: partition 1 will be revoked"]
D --> E["Consumer A finishes work and commits synchronously"]
E --> F["Partition 1 assigned to Consumer B"]
F --> G["Consumer B reads committed offset and resumes"]
Eager and cooperative rebalancing
Older rebalancing worked in an eager style: every member gave up every partition, and everything was reassigned from scratch, causing a short pause across the whole group. Modern Kafka supports cooperative rebalancing, where only the partitions that actually need to move are revoked, while the rest keep flowing. For manual commits this is a gift, because fewer partitions are disturbed, and fewer in-flight batches are exposed to the duplicate risk.
The dreaded commit failure
If a consumer tries to commit after it has already lost its partitions to a rebalance, the coordinator rejects the request with a commit failure exception. This is not a bug; it is the cluster protecting the committed position from being overwritten by a stale member. Your application should treat it as a signal: the work from that batch will be repeated by another consumer, so anything you did must tolerate being seen again.
Static group membership lets a consumer keep a fixed identity across quick restarts, so a routine redeploy does not trigger a full rebalance. This greatly reduces churn for applications that restart often.
Lost partitions versus revoked partitions
A revoked partition is handed over politely, and you may still commit for it. A lost partition means the consumer was already removed, for example after missing its deadline, so committing is no longer possible. Applications should handle the two notifications differently, saving progress in one case and simply discarding in-flight work in the other.
11The Full Lifecycle of a Manually Committed Event
Let us follow a single order event from the moment it is written until its position is safely saved.
flowchart TD
A["Producer writes order event to partition"] --> B["Consumer polls and receives a batch"]
B --> C["Application validates and processes each event"]
C --> D{"All events in batch succeeded?"}
D -->|"Yes"| E["Commit next offset"]
D -->|"No, retryable error"| F["Retry with backoff"]
F --> C
D -->|"No, poison event"| G["Send to dead letter topic"]
G --> E
E --> H["Coordinator stores position in offsets topic"]
H --> B
Written
The producer appends the event to a partition. It is assigned its offset and replicated across brokers.
Fetched
The owning consumer polls and receives the event inside a batch. The in-memory position moves forward, but nothing is saved yet.
Processed
The application performs the real work: validating, transforming, storing, or calling another system.
Committed
Only after success does the application commit the next offset. The bookmark now moves in the cluster’s durable storage.
Recoverable
From this moment, any consumer in the group that takes over the partition begins after this event.
Notice the gap between steps 3 and 4. That gap is the window in which a crash produces a duplicate. Manual commits do not remove the gap; they make it visible, small, and yours to manage. Everything in the next chapters is about living safely with that gap.
12Handling Failures: Retries, Poison Events and Dead Letters
Manual control forces you to decide what “success” means when something goes wrong.
With automatic commits, a failing event is often ignored by accident: the position moves on regardless. With manual commits, a failing event blocks progress until you decide what to do with it, because committing past it would mean giving up on it. This is good, since nothing disappears silently, but it demands a clear policy.
Temporary failures
Some failures are temporary: a database is briefly unreachable, a downstream service times out, a network blip occurs. The right response is to retry, usually with a growing delay between attempts. Do not commit while the retry is in progress. Be mindful of the poll interval, since long retry loops can push you beyond the allowed time between polls.
Poison events
A poison event is one that can never succeed, no matter how many times it is retried. Examples include malformed data, a missing required field, or a value that breaks a business rule. If you retry a poison event forever, the partition is stuck behind it, and every event after it waits. This is sometimes called head-of-line blocking.
The standard cure is a dead letter topic. After a limited number of attempts, or immediately for clearly invalid data, the application writes the failing event, together with the error reason, into a separate topic dedicated to problems. It then commits past the bad event so the healthy flow continues. People or automated tools can inspect the dead letter topic later, fix the cause, and replay the events.
A factory conveyor belt should not stop forever because one damaged part cannot be assembled. A worker removes the damaged part, places it in a “rejected” bin with a note, and lets the belt continue. The dead letter topic is that bin.
Retry in place
Try again with backoff for short, temporary problems. Keep the total time well below the poll interval.
Retry topic
Move the event to a delayed retry topic so the main partition keeps flowing while the problem clears.
Dead letter topic
Park events that can never succeed, with the reason attached, then commit past them.
Stop and alert
For events where skipping is unacceptable, halt the consumer and page a person rather than moving on.
Catching every error and committing anyway recreates the data loss of automatic commits, only with more code. Always decide deliberately which failures may be skipped and make sure skipped events are recorded somewhere.
13Making Duplicates Harmless with Idempotency
Since at-least-once means events may repeat, the safest systems are the ones where repeats do no damage.
An operation is idempotent when doing it once has the same final effect as doing it many times. Setting a light switch to “on” is idempotent: pressing it ten times leaves the light on. Adding one to a counter is not idempotent: pressing ten times gives ten.
If your processing is idempotent, then the duplicate window between processing and committing stops being a problem. A replayed event simply reproduces the same result. This is the most practical route to correct behaviour in systems that cannot use Kafka transactions, such as those writing into an ordinary database or an external service.
Practical ways to achieve idempotency
Unique event identifiers
Give every event a stable unique identifier when it is created. Before applying an event, check whether that identifier has already been recorded, and skip it if so. Store the identifier in the same database transaction as the business change.
Upserts instead of inserts
Write with “insert or update by key” semantics so a repeated event overwrites the same row with the same values rather than creating a second row.
Idempotency keys for external calls
When calling payment or messaging services, pass a key derived from the event so the remote service can recognise and ignore a repeated request.
Versioned state changes
Attach a version or sequence number to each change and apply it only if it is newer than what is stored, so late duplicates are ignored.
14Storing Offsets Outside Kafka
Sometimes the safest place for the bookmark is right next to the data it describes.
Kafka’s own commit store is excellent, but it lives in a different system from your database. That means saving your business result and saving your position are two separate actions that cannot be made atomic. There is always a tiny window between them.
An alternative is to store the offset yourself, in the same database and the same transaction as the business data. When your application writes the processed result, it also writes the partition and next offset in one transaction. Either both are saved or neither is. On startup, the consumer reads the saved positions from the database and explicitly tells Kafka to continue from them, ignoring Kafka’s own committed values.
Advantages
- Result and position are saved atomically, giving an exactly-once effect in the database.
- Recovery is simple, since the position always matches the data.
- Replaying to a chosen point is easy by editing stored positions.
- Not affected by expiry of Kafka’s committed offsets.
Disadvantages / Trade-offs
- More code: you must handle assignment, seeking and rebalances yourself.
- Kafka tooling no longer shows accurate lag for the group.
- Ties your consumer design to one database technology.
- Mistakes in position handling can be harder to notice.
Loading events into a relational store where financial or audit accuracy matters, and where you already run a transaction for each batch. It removes the duplicate window entirely for that store.
15Advantages and Trade-offs of Manual Commits
Every gain in control comes with a cost in effort. An honest comparison helps you choose.
Advantages
- Commit follows real success, preventing silent data loss.
- You choose the delivery guarantee consciously.
- Graceful hand-over during rebalances and shutdowns.
- Fine control over batch size, timing and granularity.
- Failures become visible and can be routed to retries or dead letters.
- Works with external stores for atomic result and position saving.
Disadvantages / Trade-offs
- More code and more decisions than automatic mode.
- Easy to misuse: wrong order, wrong offset value, forgotten final commit.
- Frequent synchronous commits can reduce throughput.
- Duplicates are still possible and must be handled.
- Slow processing can trigger rebalances and rejected commits.
| Trade-off | Leaning Toward Safety | Leaning Toward Speed |
|---|---|---|
| Commit frequency | After each event or small group | After large batches |
| Commit style | Synchronous | Asynchronous with final sync |
| Batch size | Small, quick to finish | Large, fewer polls |
| Failure policy | Stop and alert | Skip to dead letter and continue |
16Security, Monitoring and Metrics
A commit that cannot be seen or protected is a risk waiting to happen.
Security considerations
Committed offsets are stored in the internal offsets topic, and access to them is controlled by permissions on the consumer group resource. A consumer needs read permission on the topic it consumes and also read permission on its group. Without the group permission, commits are refused. Restricting group permissions by name prevents one team’s application from reading or overwriting another team’s saved positions.
Positions are quite valuable to an attacker or a careless operator. Resetting a group’s offsets backwards forces a mass replay that might resend emails or repeat payments. Resetting them forwards silently skips data. Limit who is allowed to reset offsets, log every reset, and prefer tooling that requires an explicit confirmation step. Also remember that data in transit between consumers and brokers should be encrypted, since commit requests reveal your group and topic names.
What to monitor
The single most useful indicator is consumer lag: the difference between the newest offset in a partition and the committed offset of the group. A steadily growing lag means the consumers are not keeping up, or that commits have stopped even though processing continues. Monitoring should track lag per partition, not just a total, since one stuck partition can hide behind healthy averages.
Consumer lag
How far behind the group is. Alert on sustained growth, not on brief spikes.
Commit latency
How long commits take. Rising values point to broker stress or network trouble.
Commit rate and failures
How often commits happen and how many fail. Sudden drops or failure bursts signal trouble.
Rebalance frequency
Frequent rebalances mean unstable members, slow processing or overly tight timeouts.
Time between polls
Shows how close processing runs to the allowed poll interval before eviction.
Dead letter volume
A rising count means upstream data quality or a new bug is producing unprocessable events.
Logging that helps in emergencies
Log each commit failure with the group, partition and offset involved, and log each rebalance with the partitions gained and lost. When a duplicate or a gap is reported by the business, these logs let you reconstruct exactly which positions were saved and when. Without them, investigations turn into guesswork.
Alert on the combination of “lag growing” and “commit rate near zero”. It reveals a consumer that is alive but has silently stopped saving progress, a state that plain health checks miss.
17Design Patterns and Anti-patterns
Some approaches have proven themselves in many teams, while others reliably cause incidents.
Patterns worth adopting
Process, then commit
The foundation of at-least-once delivery. Never reverse the order unless losing events is truly acceptable.
Async in flight, sync at the edges
Use fast asynchronous commits during normal flow and a synchronous commit on shutdown and on partition revocation.
Idempotent consumer
Design every handler so repeated events are harmless, using identifiers, upserts, or versions.
Dead letter and retry topics
Isolate unprocessable and delayed events so one bad message never blocks a whole partition.
Pause instead of stall
When downstream systems are slow, pause fetching for the affected partitions while continuing to poll, so the consumer stays alive without receiving more work than it can handle.
Transactional consume-transform-produce
For pipelines that read from Kafka and write back to Kafka, commit input positions inside the same transaction as the output events.
Anti-patterns to avoid
Problem
The application commits immediately after receiving a batch, before doing any real work, to keep the loop fast.
Why It’s Harmful
This is at-most-once delivery in disguise. Any crash during processing skips the remaining events permanently, and nobody notices until a customer complains.
Correct Approach
Commit only after processing succeeds. If speed is the concern, use asynchronous commits and smaller batches rather than moving the commit earlier.
Problem
The application catches all processing errors, logs them, and commits regardless.
Why It’s Harmful
Failed events disappear from the pipeline. The log line is the only trace, and logs are rarely reviewed with the care that business data deserves.
Correct Approach
Classify errors. Retry temporary ones, route permanent ones to a dead letter topic, and stop for the ones that must never be skipped.
Problem
Several worker threads process events from one partition, and the application commits the highest offset any thread has finished.
Why It’s Harmful
A lower event may still be running or may have failed. A crash then skips it, because the saved position is beyond it.
Correct Approach
Track completion per offset and commit only the highest offset for which every earlier event is finished.
Problem
Commits are sent from a thread other than the one that polls, because the consumer client is being shared casually.
Why It’s Harmful
The Kafka consumer client is not built for simultaneous use by multiple threads. This leads to errors and unpredictable behaviour that is very hard to reproduce.
Correct Approach
Keep all consumer calls on the single polling thread. Pass results back to it through a safe hand-off, and let that thread perform the commit.
18Best Practices and Common Mistakes
A short checklist of habits that separate reliable consumers from fragile ones.
Turn off auto commit explicitly
Do not rely on defaults. Set the switch deliberately so the intent is visible to every future reader.
Always finish with a synchronous commit
On graceful shutdown, perform one last blocking commit so the final position is certain.
Register a rebalance listener
Save progress when partitions are revoked. It is the cheapest way to shrink duplicates.
Size batches to the time budget
Choose a batch size that finishes comfortably inside the poll interval, even on slow days.
Choose the reset policy consciously
Decide whether a missing position should mean replay from the beginning or jump to newest, based on business impact.
Design for duplicates
Assume every event may arrive twice and prove your handler survives it in testing.
Test failure on purpose
Kill consumers mid-batch, force rebalances and pause brokers in a test environment, then verify no loss and harmless repeats.
Frequent mistakes, at a glance
| Mistake | Consequence | Fix |
|---|---|---|
| Committing the last processed offset instead of the next one | The last event repeats after every restart | Always commit processed offset plus one |
| Forgetting the final commit on shutdown | Recent work is reprocessed at next start | Commit synchronously before closing the consumer |
| Slow handlers with default poll interval | Consumer evicted, commits rejected, duplicates | Reduce batch size or speed up handlers |
| Retrying forever on a bad event | Partition blocked for everyone behind it | Limit attempts, then dead letter |
| Ignoring commit failures | Progress silently not saved | Log, alert, and treat as a rebalance signal |
| Leaving the reset policy at its default | Surprise replay or skipped data after expiry | Choose earliest or latest deliberately |
Manual commits are a tool, not a guarantee. They give you the power to be correct, but correctness still depends on the order of your steps, the quality of your error handling, and how well your handlers tolerate repeats.
19Real-World and Industry Examples
The same idea shows up wherever an event stream must be trusted.
Online retail order pipeline
An order event triggers stock reservation, payment capture and a confirmation email. The consumer commits only after the order record is safely stored. If it crashes midway, the order is replayed, and idempotency keys stop the customer being charged or emailed twice.
Banking and payments
Transaction events are applied to account ledgers. Positions are stored in the same database transaction as ledger entries, so the balance and the bookmark can never disagree. Auditors can trace every entry to a source offset.
Ride-hailing and logistics
Location updates are high volume and individually unimportant, so commits are asynchronous and infrequent. Trip-completion events, by contrast, use small batches and strict commit-after-success, since they drive billing.
Data warehouse loading
Consumers gather thousands of events, write them as one file or one bulk insert, and commit only once the load has succeeded. A failed load simply reruns the same range, and loading logic deduplicates by event identifier.
Fraud detection
Scoring services read transactions, publish risk decisions, and commit input positions in the same transaction as the decisions, so each transaction is scored exactly once from the downstream point of view.
Notification services
Emails and text messages are hard to un-send, so these consumers pair manual commits with a delivery log keyed by event identifier. Before sending, they check whether a message for that event already went out.
20Frequently Asked Questions
Quick answers to the questions that come up most often.
No. Committing only saves a position for the group. The events remain in the partition until the topic’s retention rules remove them, and other groups can still read them independently.
Yes. Each group keeps its own bookmark. This is why several applications can consume the same topic at different speeds without interfering with each other.
No. They guarantee that the position is saved only when you say so. A crash between finishing work and committing still leads to a repeat. Prevent harm by making handlers idempotent or by using transactions.
It gives you certainty about the result and retries temporary errors, so it is safer for a single call. But an application that combines asynchronous commits in the loop with a final synchronous commit is normally just as safe overall, and much faster.
The consumer keeps reading forward in memory, but nothing is saved. After any restart, it begins at the last committed position, or follows the reset policy if none exists, and you re-read everything since.
As often as the cost of repeating work justifies. Per batch is a solid default. Commit more often when repeats are expensive, and less often when repeats are cheap and throughput matters most.
Usually because the time between polls exceeds the allowed interval, often due to slow handlers or oversized batches, or because heartbeats are missed from long pauses in the process. Shrink batches, speed up handlers or adjust timeouts carefully.
Acknowledging is the framework’s friendly name for the same idea. When your handler acknowledges a record or batch in manual mode, the framework performs the offset commit on your behalf at the appropriate moment.
Yes, using administrative tools or by seeking within the consumer. This is a legitimate way to replay events after fixing a bug, but it should be controlled carefully because it will re-trigger all side effects of the replayed events.
In a transactional consume-transform-produce pipeline, offsets are committed as part of the transaction rather than through the ordinary commit calls, and automatic committing must be off. So you still take control of offsets, just in a different, atomic way.
21Summary and Key Takeaways
An offset is simply a bookmark inside a partition, and committing is the act of saving that bookmark to the cluster so any consumer in the group can continue from it. Automatic committing saves the bookmark on a timer, unaware of what your application actually accomplished, which makes both loss and duplication possible. Manual offset commits switch that responsibility to your code, so the bookmark moves only when you have honestly finished the work before it.
Using manual commits well means understanding a handful of ideas together: commit the next offset, not the last one; process before you commit; combine fast asynchronous commits with a firm synchronous commit at shutdown and on revocation; keep processing time inside the poll interval; and design your handlers so that a repeated event is harmless. Add sensible failure paths with retries and dead letter topics, watch lag and commit health, and protect who may reset positions. Do these things, and the small gap between “finished” and “saved” becomes a managed, understood part of your system rather than a hidden source of surprises.
Key Takeaways
- An offset is a position, a commit is a saved position — the committed value is always the next event to read, which is the last processed offset plus one.
- Automatic commits are timer-driven — they ignore the real result of processing, so they can skip unfinished work or repeat finished work.
- Order decides the guarantee — commit-then-process gives at-most-once, process-then-commit gives at-least-once, and atomic processing with committing gives an exactly-once effect.
- Blend synchronous and asynchronous — commit asynchronously in the steady loop for speed, and synchronously on shutdown and partition revocation for certainty.
- Respect the poll interval and rebalances — slow processing gets consumers evicted and commits rejected, so size batches to the time budget and save progress when partitions are revoked.
- Make duplicates boring — idempotent handlers, unique identifiers and dead letter topics turn unavoidable repeats and bad events into harmless, visible non-events.
- Monitor and protect the bookmark — track lag, commit failures and rebalances per partition, and tightly control who is allowed to reset a group’s offsets.