Manual Offset Commits: Taking Control of What Your Consumer Has Truly Processed

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.

Simple Analogy

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.

Term 1

Topic

A named stream of related events, such as “orders” or “payments”.

Term 2

Partition

One ordered log inside a topic. Ordering is guaranteed only within a single partition.

Term 3

Offset

The position number of an event inside one partition, counting up from zero.

Term 4

Consumer

An application that reads events from one or more partitions.

Term 5

Consumer Group

A team of consumers sharing a name, splitting the partitions of a topic among themselves.

Term 6

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.

!
Common Mistake

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.

1 per group
saved position per partition, per consumer group
50
default partitions of the internal offsets topic
offset + 1
the value that should be committed after processing
i
Good to Know

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.

Simple Analogy

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
        
FIG 1 — How an automatic commit can skip unfinished work
!
Misconception

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.

“A committed offset should be a promise that the work before it is done, not a guess that it probably is.”

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.

AspectAutomatic CommitManual Commit
Who decides when to saveA timer inside the clientYour application logic
Link to processing resultNoneDirect, commit follows success
Loss riskPresentAvoidable with correct ordering
Duplicate riskPresentReduced, and manageable with idempotent work
Code effortAlmost noneModerate, needs careful design
Best forLow-stakes streamsBusiness-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.

Sync

Blocking and certain

Waits for the answer, retries temporary failures, and raises an exception on permanent ones.

Async

Fast and fire-and-forget

Returns instantly, reports the result to an optional callback, and never retries on its own.

Simple Analogy

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.

QuestionSynchronousAsynchronous
Blocks the loop?YesNo
Automatic retry?Yes, for temporary errorsNo
Knows result immediately?YesOnly through a callback
Throughput impactHigher cost per commitMinimal
Best moment to useShutdown, rebalance, critical checkpointsSteady-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.

1

Per event

Smallest redo window, highest commit traffic. Use for rare, costly events.

2

Per N events

A balanced compromise. Commit after every few hundred events or every few seconds of work.

3

Per batch

The default sensible choice for most applications. One commit per poll result.

4

Per partition with explicit offsets

Highest control. Ideal when worker threads finish out of order and you must commit only safe positions.

i
Rule of Thumb

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.

!
Danger with Worker Pools

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"]
        
FIG 2 — How step ordering decides the delivery guarantee
GuaranteeOrder of StepsFailure OutcomeTypical Use
At-most-onceCommit, then processEvent may be lostNon-critical telemetry
At-least-onceProcess, then commitEvent may repeatMost business pipelines
Exactly-once effectAtomic process and commit, or idempotent designNo visible loss or repeatPayments, ledgers, inventory
Simple Analogy

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.

1

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.

2

The request reaches the coordinator

The client first finds the coordinator broker, then sends the offset commit request directly to it.

3

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.

4

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.

5

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
        
FIG 3 — The path of one manual offset commit

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.

i
Design Tip

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.

Setting

Max poll records

Caps how many events one poll returns. Smaller batches finish faster and shrink the redo window.

Setting

Max poll interval

The longest permitted gap between polls. Exceed it and the consumer is removed from the group.

Setting

Session timeout

How long the coordinator waits without a heartbeat before assuming the member has failed.

Setting

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.

!
Common Trap

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.

Simple Analogy

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"]
        
FIG 4 — A graceful hand-over of a partition during a rebalance

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.

i
Stability Aid

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
        
FIG 5 — Lifecycle of a batch under manual commit control
1

Written

The producer appends the event to a partition. It is assigned its offset and replicated across brokers.

2

Fetched

The owning consumer polls and receives the event inside a batch. The in-memory position moves forward, but nothing is saved yet.

3

Processed

The application performs the real work: validating, transforming, storing, or calling another system.

4

Committed

Only after success does the application commit the next offset. The bookmark now moves in the cluster’s durable storage.

5

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.

Simple Analogy

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.

Strategy 1

Retry in place

Try again with backoff for short, temporary problems. Keep the total time well below the poll interval.

Strategy 2

Retry topic

Move the event to a delayed retry topic so the main partition keeps flowing while the problem clears.

Strategy 3

Dead letter topic

Park events that can never succeed, with the reason attached, then commit past them.

Strategy 4

Stop and alert

For events where skipping is unacceptable, halt the consumer and page a person rather than moving on.

!
Common Mistake

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.

“You cannot always prevent a repeat, but you can always make a repeat boring.”

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.
i
When It Shines

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-offLeaning Toward SafetyLeaning Toward Speed
Commit frequencyAfter each event or small groupAfter large batches
Commit styleSynchronousAsynchronous with final sync
Batch sizeSmall, quick to finishLarge, fewer polls
Failure policyStop and alertSkip 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.

Metric

Consumer lag

How far behind the group is. Alert on sustained growth, not on brief spikes.

Metric

Commit latency

How long commits take. Rising values point to broker stress or network trouble.

Metric

Commit rate and failures

How often commits happen and how many fail. Sudden drops or failure bursts signal trouble.

Metric

Rebalance frequency

Frequent rebalances mean unstable members, slow processing or overly tight timeouts.

Metric

Time between polls

Shows how close processing runs to the allowed poll interval before eviction.

Metric

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.

i
Operational Tip

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

ANTI-PATTERN-01 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.

ANTI-PATTERN-02 Avoid
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.

ANTI-PATTERN-03 Avoid
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.

ANTI-PATTERN-04 Avoid
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.

1

Turn off auto commit explicitly

Do not rely on defaults. Set the switch deliberately so the intent is visible to every future reader.

2

Always finish with a synchronous commit

On graceful shutdown, perform one last blocking commit so the final position is certain.

3

Register a rebalance listener

Save progress when partitions are revoked. It is the cheapest way to shrink duplicates.

4

Size batches to the time budget

Choose a batch size that finishes comfortably inside the poll interval, even on slow days.

5

Choose the reset policy consciously

Decide whether a missing position should mean replay from the beginning or jump to newest, based on business impact.

6

Design for duplicates

Assume every event may arrive twice and prove your handler survives it in testing.

7

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

MistakeConsequenceFix
Committing the last processed offset instead of the next oneThe last event repeats after every restartAlways commit processed offset plus one
Forgetting the final commit on shutdownRecent work is reprocessed at next startCommit synchronously before closing the consumer
Slow handlers with default poll intervalConsumer evicted, commits rejected, duplicatesReduce batch size or speed up handlers
Retrying forever on a bad eventPartition blocked for everyone behind itLimit attempts, then dead letter
Ignoring commit failuresProgress silently not savedLog, alert, and treat as a rebalance signal
Leaving the reset policy at its defaultSurprise replay or skipped data after expiryChoose earliest or latest deliberately
!
Never Forget

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.

Money
strict commit-after-success, atomic stores
Messages
idempotency keys and delivery logs
Telemetry
async commits, relaxed guarantees

20Frequently Asked Questions

Quick answers to the questions that come up most often.

Q1Does committing an offset delete the event from Kafka?

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.

Q2Can two different consumer groups have different committed offsets on the same partition?

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.

Q3Do manual commits guarantee no duplicates?

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.

Q4Is a synchronous commit always safer than an asynchronous one?

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.

Q5What happens if I never commit?

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.

Q6How often should I commit?

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.

Q7Why does my consumer keep being kicked out of the group?

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.

Q8What is the difference between committing and acknowledging in frameworks?

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.

Q9Can I move a group’s position backwards on purpose?

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.

Q10Do I need manual commits if I use Kafka transactions?

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.