Consumer & Manual Commits
Disable EnableAutoCommit. Use Consume loop with CancellationToken. Call StoreOffset and Commit only after successful processing.
Consumer Configuration for Correctness and Control
Picture a billing consumer that ran flawlessly in staging, then shipped to production and quietly skipped a full day of invoices the moment it was deployed as a brand-new consumer group. Nobody touched the processing logic. The bug was sitting in the ConsumerConfig the whole time β a handful of flags that decide, before a single line of your handler code executes, whether messages get lost, replayed, or processed exactly when you expect. This section is about those flags: GroupId, AutoOffsetReset, EnableAutoCommit, EnableAutoOffsetStore, and the timing knobs session.timeout.ms, heartbeat.interval.ms, and max.poll.interval.ms. Get these wrong and no amount of careful commit logic later will save you.
GroupId: What You're Actually Opting Into
GroupId is not a label for your consumer β it's a coordination key. Every consumer instance that connects with the same GroupId becomes a member of the same consumer group, and the group divides the subscribed topic's partitions across the currently active members (in the classic protocol an elected consumer computes the assignment and the coordinator distributes it) so that, at steady state, each partition is owned by exactly one member of the group at a time.
var consumerConfig = new ConsumerConfig
{
BootstrapServers = "localhost:9092",
GroupId = "billing-service", // members sharing this string share partitions
AutoOffsetReset = AutoOffsetReset.Earliest,
EnableAutoCommit = false,
EnableAutoOffsetStore = false
};
The practical consequence: if you run three instances of billing-service with the same GroupId against a topic with six partitions, each instance ends up owning roughly two partitions β and Kafka, not your code, decides which ones. If you instead give each instance a different GroupId, they stop cooperating entirely; each one behaves as an independent group and receives all partitions, meaning every instance reads every message. This is the classic mistake behind "why is my downstream system getting triplicate events" β someone generated a unique GroupId per pod (for example, embedding a hostname or GUID), and each pod ended up as a group of one, happily consuming the entire topic on its own.
π― Key Principle: GroupId answers the question "who am I sharing partitions with?" β not "what should I call myself in logs." Two consumers with the same GroupId divide the work; two consumers with different GroupIds duplicate the work.
The mechanics of how partitions get reassigned when a member joins or leaves β the rebalance protocol itself β belongs to a different part of this lesson, in "Partition Assignment, Rebalances, and Commit Timing." What matters here is simpler: GroupId is the first configuration decision you make, and it determines whether your fleet of consumer instances behaves as one cooperating group or as several independent, fully-duplicating readers.
AutoOffsetReset: What Happens With No History
Every consumer group has committed offsets stored per topic-partition β its bookmark. But a brand-new consumer group has no bookmark at all, and AutoOffsetReset decides what happens the first time that group tries to read a partition with nothing committed.
| Value | Behavior for a fresh group | When it's the right call |
|---|---|---|
| π’ Earliest | Starts from the oldest retained message in the partition | Billing, auditing, anything where missing history means missing money or facts |
| π΅ Latest | Starts from the newest offset β ignores everything already in the log | Live dashboards, ephemeral notifications, "only care about what happens next" |
| π΄ Error | Throws instead of picking a default, forcing you to decide explicitly | Systems where silently guessing a starting point would be a correctness bug |
The distinction only matters when there's no committed offset to resume from β once a group has committed at least once for a partition, AutoOffsetReset is irrelevant for that partition; the consumer resumes from the committed offset every time, regardless of this setting. It only fires on first contact, or if a committed offset has aged out of retention and is no longer valid.
In Confluent.Kafka, AutoOffsetReset defaults to Latest if you don't set it β meaning an unconfigured new consumer group in production will silently start from "whatever comes in from now on" and skip the entire existing backlog. For a service like billing-service, that default is exactly how you lose a full day of invoices: the group is new, the topic already has a full day of orders sitting in it, and Latest tells the consumer "none of that concerns you." Setting AutoOffsetReset = AutoOffsetReset.Earliest explicitly, as in the config above, is the safer default for most business-critical consumers β you'd rather reprocess a backlog you've already handled (which idempotent processing absorbs) than silently skip data you haven't.
π‘ Pro Tip: Treat AutoOffsetReset as a decision you write down, not a default you inherit. If you catch yourself relying on whatever the library ships with, you've already made the decision by accident.
EnableAutoCommit and EnableAutoOffsetStore: Two Switches, Not One
This pair causes more confusion than any other setting in ConsumerConfig, because it looks like one decision ("manual commits, yes or no?") but is actually two independent mechanisms layered on top of each other.
Internally, Confluent.Kafka's consumer keeps a local, in-memory offset store per assigned partition β think of it as "the offset I'd commit next if asked." Two separate things touch that store:
π§ EnableAutoOffsetStore controls whether the client automatically writes an offset into that in-memory store every time Consume() hands you a message β specifically, the offset after the message you just received β offset + 1, the position to resume from β gets stored automatically, before you've done anything with it.
π§ EnableAutoCommit controls whether a background timer periodically takes whatever is currently sitting in that in-memory store and commits it to the broker, on a fixed interval, independent of whether you've finished processing anything.
The two flags default to true in Confluent.Kafka. That combination β auto-store plus auto-commit β is what makes the library usable out of the box for a demo: consume a message, the offset gets stored immediately, and a timer commits it later, no matter what your code did with the message in between.
The trap is disabling only one of them. If you set EnableAutoCommit = false but leave EnableAutoOffsetStore at its default of true, you've stopped the periodic commit, but the client is still auto-storing the offset of every message the instant Consume() returns it β meaning that whenever you do eventually call Commit() yourself (say, on shutdown, or via some other codepath), it commits whatever was auto-stored, which may include messages you haven't actually finished processing yet. You think you have manual control; you actually have manual timing over an offset value you didn't manually set.
// The combination that gives you real manual control over WHEN and WHICH
// offset gets committed:
var consumerConfig = new ConsumerConfig
{
BootstrapServers = "localhost:9092",
GroupId = "billing-service",
AutoOffsetReset = AutoOffsetReset.Earliest,
EnableAutoCommit = false, // no background commit timer
EnableAutoOffsetStore = false // no automatic "offset seen" bookkeeping either
};
// Later, in the consume loop, after processing succeeds:
consumer.StoreOffset(consumeResult); // you decide the offset is safe to store
consumer.Commit(); // flushes the stored offset to the broker
With both flags off, StoreOffset and Commit become entirely your responsibility, called only after your handler has actually finished successfully. The full call sequence and how it interacts with batching commits belongs to the dedicated manual-commit lesson β what matters here is that EnableAutoOffsetStore is the flag deciding which offset gets committed, so it is the one that must be off for any manual sequence to mean what you think it means. Turning off only EnableAutoCommit is the half-measure that bites: it still lets the client silently mark messages as "seen" before you've confirmed they were handled.
| EnableAutoCommit | EnableAutoOffsetStore | What actually happens |
|---|---|---|
| β true | β true | Fully automatic β offset stored on receive, committed on a timer, regardless of processing outcome |
| β false | β true | No timer commit, but offsets are still auto-stored β any manual Commit() you call may include unprocessed messages |
| β true | β false | You call StoreOffset only after processing succeeds; the timer flushes it in the background β Confluent's recommended high-throughput at-least-once pattern |
| β false | β false | Full manual control β you decide exactly when an offset is stored and when it's committed |
β οΈ Common Mistake: Setting EnableAutoCommit = false and assuming that alone gives you manual commits. It doesn't β it only removes the timer. The offset-store auto-population from EnableAutoOffsetStore keeps running unless you turn it off too. This exact gap is revisited as a standalone pitfall in "Common Pitfalls and Pre-Production Checklist," because it's one of the most repeated configuration bugs in raw Confluent.Kafka consumers.
The Timing Knobs That Decide Whether You're "Alive"
Even with commit behavior nailed down, three timing settings decide something orthogonal but equally important: whether the broker still considers your consumer a live member of the group at all. Get these wrong and you can be actively, correctly processing messages while the broker has already decided you're dead and handed your partitions to someone else.
| Setting | What it governs | Confluent.Kafka default |
|---|---|---|
| β±οΈ session.timeout.ms | How long the broker waits without a heartbeat before declaring the member dead | 45000 (45s) |
| π heartbeat.interval.ms | How often a background thread sends a heartbeat to the group coordinator | 3000 (3s) |
| β³ max.poll.interval.ms | How long the client itself tolerates between successive calls to Consume() before leaving the group on your behalf | 300000 (5 min) |
The first two operate on their own background thread inside the client and are relatively forgiving: as long as your process is alive and the network is healthy, heartbeats go out every heartbeat.interval.ms, and the broker only declares the member gone if it hears nothing for session.timeout.ms. This mechanism catches genuine failures β a crashed process, a frozen container, a severed network link.
max.poll.interval.ms is the one that catches you off guard, because it's not about whether your process is alive β it's about whether you're calling Consume() frequently enough. The client sends heartbeats from a separate thread, so a slow synchronous handler doesn't stop the heartbeats directly. But librdkafka itself tracks the time between polls, and if you take longer than max.poll.interval.ms to loop back and call Consume() again β because you're stuck doing a slow database write, a slow external HTTP call, or heavy synchronous processing on the message you just received β the client concludes you've stalled and sends a "leave group" on your behalf, which triggers a rebalance even though your process never crashed and your heartbeats never stopped. The broker never sees how often you call Consume(); this watchdog is entirely client-side.
π€ Did you know? This means a consumer can be fully "alive" by every heartbeat measure and still drop out of the group, purely because its handler is slow. The interaction between this specific timeout and slow processing β including how it produces an unwanted rebalance mid-work β is developed further in "Partition Assignment, Rebalances, and Commit Timing," since that's where the rebalance-handling mechanics live.
For now, the configuration-level takeaway is: these three numbers are not independent tuning knobs to leave at their defaults and forget. If your handler occasionally does slow work β a retry with backoff, a downstream call with a generous timeout β you need max.poll.interval.ms set high enough to cover the realistic worst case of a single poll-to-poll cycle, not the average case. Setting it too low for a legitimately slow (but not stuck) handler causes exactly the kind of self-inflicted rebalance churn that looks like a Kafka reliability problem but is actually a configuration mismatch with your own processing time.
The Ceiling You Set Before Writing Any Commit Logic
Here's the part that ties this whole section together: the combination of EnableAutoCommit, EnableAutoOffsetStore, and AutoOffsetReset doesn't just affect convenience β it sets a ceiling on which delivery semantic is even reachable, before you write a single line of processing or commit code.
If EnableAutoCommit stays true while EnableAutoOffsetStore is also left at its default, the broker will periodically receive commits on a timer, detached from whether your handler succeeded. That configuration alone makes at-most-once loss possible: the timer can fire and commit an offset for a message whose processing crashed, hung, or threw halfway through, and that message is now permanently behind the committed offset, never to be redelivered. No amount of careful try/catch logic in your handler changes this, because the commit already happened on the timer's schedule, not yours.
Set EnableAutoCommit = false and EnableAutoOffsetStore = false, and you've removed the ceiling β you've made at-least-once delivery possible, because now nothing commits until you explicitly say so. But turning both flags off doesn't automatically give you at-least-once; it only makes it achievable. Whether you actually get there depends on where in your code you call StoreOffset and Commit relative to your processing logic β commit before finishing the work and you're back to at-most-once risk with extra steps; commit only after the work succeeds and you get the safer default. Walking through both of those crash scenarios concretely β what a process crash mid-processing actually does to redelivery in each case β is the focus of "From Auto-Commit to At-Least-Once: Seeing the Guarantees Play Out."
β
Correct thinking: Configuration decides what's possible; the placement of your commit calls decides what actually happens. A ConsumerConfig with manual commits enabled but a Commit() call placed before your processing logic gives you the same loss risk as leaving auto-commit on β you've just moved the risk into your own code instead of the library's timer.
β Wrong thinking: "I disabled EnableAutoCommit, so my consumer is at-least-once now." Disabling it removes the automatic ceiling of at-most-once risk; it does not, by itself, install at-least-once behavior. That still requires calling StoreOffset/Commit in the right place, after successful processing β the exact call sequence and pattern for doing that correctly is the subject of the dedicated manual commit pattern lesson referenced elsewhere in this course.
π Quick Reference Card: Configuration Ceiling
| π§ Setting | π― What it caps or unlocks |
|---|---|
| EnableAutoCommit = true, EnableAutoOffsetStore = true | Caps you at at-most-once risk, no matter how careful your handler is |
| EnableAutoCommit = false + EnableAutoOffsetStore = false | Unlocks at-least-once β but only if commit calls happen after processing succeeds |
| AutoOffsetReset = Latest on a new group | Caps you at "backlog is invisible," independent of any commit strategy |
| AutoOffsetReset = Earliest on a new group | Unlocks full backlog visibility from first run |
π‘ Mental Model: Think of ConsumerConfig as pouring the foundation of a building, and your commit logic as the framing that goes on top. You can frame a house beautifully, but if the foundation was poured for a bungalow, you're not getting a three-story building no matter how good the framing is. EnableAutoCommit, EnableAutoOffsetStore, and AutoOffsetReset are the foundation; everything you do afterward in the consume loop operates within the limits they set.
Building the Consume Loop
Once the ConsumerConfig is set the way you want it β manual commits, manual offset storage, sensible timeouts β you still have to write the code that actually pulls messages off the broker. This is where a surprising number of raw Confluent.Kafka consumers go wrong: not because the config was wrong, but because the loop around Consume() was written like a demo instead of a service that has to survive bad messages, broker hiccups, and a Ctrl+C.
Wiring the Consumer: Subscribe vs. Assign
You build a consumer with ConsumerBuilder<TKey, TValue>, generic over the types your deserializers produce. For most business services that's something like ConsumerBuilder<string, string> (JSON payload as a string) or ConsumerBuilder<string, byte[]> if you're deserializing Protobuf yourself downstream.
using Confluent.Kafka;
var consumerConfig = new ConsumerConfig
{
BootstrapServers = "localhost:9092",
GroupId = "billing-service",
AutoOffsetReset = AutoOffsetReset.Earliest,
EnableAutoCommit = false,
EnableAutoOffsetStore = false
};
using var consumer = new ConsumerBuilder<string, string>(consumerConfig)
.SetErrorHandler((_, error) =>
Console.WriteLine($"Broker error: {error.Code} - {error.Reason} (fatal: {error.IsFatal})"))
.Build();
consumer.Subscribe("orders");
SetErrorHandler gives you visibility into broker-level problems (connection drops, metadata refresh failures) that the client is handling internally β these fire independently of the exceptions Consume() throws, and logging them here is cheap insurance against a silent, slowly-degrading consumer.
The call that matters most in this snippet is Subscribe. It tells Kafka "I want partitions from this topic, assigned to me dynamically as part of my consumer group." The group β not your code β decides which partitions this instance gets, and that assignment can change any time a rebalance happens β a topic covered in depth in "Partition Assignment, Rebalances, and Commit Timing." Subscribe is what almost every service should use: it's how Kafka gives you automatic load distribution across however many instances of billing-service you happen to be running.
The alternative is Assign, which hands the consumer an explicit, fixed list of TopicPartition (or TopicPartitionOffset) values instead of asking the group coordinator for an assignment:
consumer.Assign(new List<TopicPartition>
{
new TopicPartition("orders", new Partition(0)),
new TopicPartition("orders", new Partition(1))
});
With Assign, this consumer instance owns exactly those partitions, period β no group coordinator reshuffles them to another member, because Assign sidesteps the rebalance protocol entirely. You reach for Assign in narrower situations: a tool that needs to read a specific partition for debugging, a service that implements its own external partition-to-instance mapping instead of trusting Kafka's rebalance protocol, or a scenario where you deliberately want a single, unshared reader of a partition (for example, a stateful process that must never have two instances touching the same partition at once, and wants to control that itself rather than relying on group membership). For the vast majority of billing-service-style consumers, Subscribe is the default and Assign is the exception you reach for on purpose, not by accident.
The Loop: Consume(CancellationToken)
Once subscribed, the actual work is a loop around consumer.Consume(). This method is blocking β it parks the calling thread until a message arrives, a partition-EOF event fires, or something goes wrong. Confluent.Kafka gives you two ways to call it: with a TimeSpan timeout, or with a CancellationToken.
The TimeSpan overload returns null when the timeout elapses with nothing to consume, which forces you to write a polling loop that keeps calling Consume(TimeSpan) over and over, checking for null, checking some separate shutdown flag, and spinning. It works, but it's an awkward fit for anything that needs to shut down promptly and cleanly.
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true; // don't let the runtime kill the process; let the loop exit gracefully
cts.Cancel();
};
try
{
while (!cts.IsCancellationRequested)
{
try
{
var result = consumer.Consume(cts.Token);
if (result.IsPartitionEOF)
{
continue; // reached the end of the partition; nothing to process yet
}
Console.WriteLine(
$"{result.Topic}[{result.Partition.Value}]@{result.Offset.Value} " +
$"key={result.Message.Key} value={result.Message.Value}");
// process the message here, then StoreOffset + Commit only after success
// (the exact call sequence is covered in the manual-commit pattern lesson)
}
catch (ConsumeException ex)
{
// never swallow this silently β log where it happened, then decide
// whether to stop (fatal) or keep polling (transient). See below.
Console.Error.WriteLine(
$"Consume failed: {ex.Error.Reason} " +
$"(fatal={ex.Error.IsFatal}) at {ex.ConsumerRecord?.TopicPartitionOffset}");
if (ex.Error.IsFatal)
{
throw;
}
}
}
}
catch (OperationCanceledException)
{
// expected: cts was cancelled while Consume() was blocking
}
finally
{
consumer.Close();
}
Passing a CancellationToken to Consume means that when the token is cancelled β because your host is shutting down, or a supervisor asked the service to stop β the blocked call unblocks immediately by throwing OperationCanceledException, rather than making you wait out an arbitrary timeout window or poll a flag on a timer. That's a real difference in shutdown latency: with a TimeSpan-based loop, your shutdown responsiveness is capped by however long that timeout is; with a CancellationToken, cancellation is observed as soon as the runtime notices it, typically far faster and without you having to tune a magic number. It also composes naturally with IHostedService/BackgroundService patterns in ASP.NET Core, which hand you a CancellationToken for exactly this purpose.
β οΈ Common Mistake: Catching OperationCanceledException inside the while loop's try block instead of around the whole loop. If you swallow it per-iteration without breaking, the loop can spin trying to call Consume again on an already-cancelled token, throwing the same exception repeatedly until something else stops it. Let the cancellation exception propagate out of the loop entirely, as in the example above, and put your cleanup (consumer.Close()) in a finally.
Reading a ConsumeResult
When Consume() returns successfully, it hands you a ConsumeResult<TKey, TValue> β not just the message payload, but everything you need to know where that message came from and whether there's actually a message there at all.
The fields that matter day to day:
| Field | What it tells you |
|---|---|
Topic |
Which topic this record came from |
Partition |
Which partition, as a Partition struct (.Value gives the int) |
Offset |
The record's position in that partition, as an Offset struct (.Value gives the long) |
TopicPartitionOffset |
The three above bundled together β the exact coordinate you'll later commit |
Message.Key / Message.Value |
Your deserialized key and value, typed as TKey/TValue |
Message.Timestamp |
When the broker (or producer) recorded the message |
IsPartitionEOF |
True when the consumer has caught up to the end of the partition, not a real message |
IsPartitionEOF only ever becomes relevant if you've set EnablePartitionEof = true in your ConsumerConfig; by default it's off and every ConsumeResult you get back represents an actual message. When it is enabled and you hit the end of a partition, Consume() returns a ConsumeResult with IsPartitionEOF = true β and critically, Message on that result is null. This matters mechanically: if your loop's first move is result.Message.Value, checking IsPartitionEOF after that will already have thrown a NullReferenceException. The check has to come first, exactly as in the loop above, before you ever touch Message.
π‘ Pro Tip: IsPartitionEOF is genuinely useful for batch-style consumers that need to know "I've drained everything currently available, time to flush a batch or checkpoint progress" rather than waiting indefinitely for the next message that might not arrive for a while.
When Consume() Throws: Fatal vs. Transient
Not every problem behind a Kafka consumer shows up as a graceful return value β some surface as a ConsumeException thrown out of Consume() itself. The exception carries an Error object, and the single most important thing on it is Error.IsFatal.
catch (ConsumeException ex)
{
if (ex.Error.IsFatal)
{
Console.WriteLine($"Fatal consumer error, stopping loop: {ex.Error.Code} - {ex.Error.Reason}");
throw; // let it propagate; this consumer instance cannot recover, rebuild it
}
Console.WriteLine($"Non-fatal consume error, continuing: {ex.Error.Code} - {ex.Error.Reason}");
// fall through to the next loop iteration and try again
}
Error.IsFatal = true means the underlying client has entered a state it cannot recover from on its own β for example, an authentication failure that will never succeed by retrying, or an internal client error that leaves the consumer instance unusable. When you see IsFatal, the correct move is to stop the loop, dispose the consumer, and either let a supervisor restart the whole process or explicitly rebuild a fresh IConsumer from a new ConsumerBuilder. Continuing to call Consume() on a fatally broken client just produces more of the same exception in a tight, noisy loop.
Error.IsFatal = false covers the much more common case: something recoverable happened, and the client is still usable. Most transient broker-connectivity issues (a broker temporarily unreachable, a metadata refresh that needs to retry) are actually surfaced through the SetErrorHandler callback you wired up earlier while the client keeps retrying underneath, without ever throwing out of Consume() at all β which is exactly why that error handler is worth having. The ConsumeException path you catch directly in the loop is reserved for narrower, message-scoped problems, the biggest of which is deserialization failure.
π― Key Principle: Treat Error.IsFatal as the fork in the road for your loop's error handling β true means stop and rebuild, false means log and keep polling. Don't try to invent finer-grained recovery logic per error code unless you have a specific, known reason to; the fatal flag already encodes the distinction that matters operationally.
Poison Messages: Isolating Deserialization Failures
Here's the scenario that kills naive consumer loops in production: one badly-formed message lands in the topic β a producer bug, a schema mismatch, someone hand-crafting a test message with kafka-console-producer β and your deserializer throws. If that exception isn't handled distinctly from "the broker is unreachable," it can either crash the whole service or, worse, get treated the same as a fatal client error and stop a perfectly healthy consumer from ever reading the next, perfectly good message.
The good news is that Confluent.Kafka surfaces deserialization failures as a ConsumeException too, but with an important detail: the exception's own ConsumerRecord property usually still tells you exactly where the bad message lives, even though the typed Message.Value you wanted couldn't be produced.
try
{
var result = consumer.Consume(cts.Token);
// ... normal processing ...
}
catch (ConsumeException ex) when (
ex.Error.Code == ErrorCode.Local_ValueDeserialization ||
ex.Error.Code == ErrorCode.Local_KeyDeserialization)
{
var badResult = ex.ConsumerRecord;
Console.WriteLine(
$"Poison message at {badResult.Topic}[{badResult.Partition.Value}]" +
$"@{badResult.Offset.Value} failed to deserialize, sending to DLQ");
// send the raw bytes to a dead-letter topic here, then let the loop continue
}
catch (ConsumeException ex)
{
if (ex.Error.IsFatal)
{
throw;
}
Console.WriteLine($"Non-fatal consume error, continuing: {ex.Error.Reason}");
}
C# tries catch clauses with when filters in the order they're written, so the deserialization-specific handler above runs first and only falls through to the general ConsumeException handler for everything else. This separation is what lets one malformed record become a logged, routed-to-DLQ event instead of a stack trace that takes down the process β the deserialization branch never checks IsFatal at all, because a bad payload on one message says nothing about whether the client connection itself is healthy.
β οΈ Common Mistake: Writing a single, undifferentiated catch (ConsumeException ex) block that logs ex.Message and moves on for everything. This technically "doesn't crash," but it also means you never distinguish a poison message (skip it, DLQ it, move on) from a fatal client failure (which needs the loop to actually stop) β you'll either keep hammering a broken client in a tight retry loop, or, in the opposite failure, treat a genuinely dead consumer as if it just needs to try the next message.
π‘ Real-World Example: Imagine orders normally carries JSON Order objects, but a misconfigured upstream service briefly publishes plain-text log lines to the same topic by mistake. Without the deserialization-specific catch, every one of those lines throws, and if your loop treats that the same as a fatal error, billing-service stops consuming entirely until someone notices and manually restarts it β even though every message after those bad lines is perfectly fine. With the pattern above, each bad line gets logged with its exact partition and offset, routed wherever your dead-letter strategy sends it, and the loop moves straight on to the next, valid order.
Getting this loop right β Subscribe for normal group membership, Consume(CancellationToken) for responsive shutdown, careful reading of IsPartitionEOF before touching Message, and a layered catch that separates poison messages from fatal client failures β is what turns "it works in the demo" into a consumer that survives a bad message at 2 AM without anyone paging on-call.
Partition Assignment, Rebalances, and Commit Timing
A raw Confluent.Kafka consumer doesn't just poll and process β it also has to survive the moment another member joins the group, a pod gets rescheduled, or the group coordinator decides your partitions belong to someone else. That moment is a rebalance, and it is where consumers that look correct in a demo start silently reprocessing work or dropping it in production. The mechanism that determines which outcome you get isn't the processing code itself β it's whether you commit offsets at the right instant relative to the rebalance.
The Three Rebalance Callbacks and When They Fire
Confluent.Kafka exposes three hooks on ConsumerBuilder that let you react to changes in partition ownership: SetPartitionsAssignedHandler, SetPartitionsRevokedHandler, and SetPartitionsLostHandler. Each corresponds to a different transition in the consumer's relationship with its partitions, and each hands you different guarantees about what you can safely do inside it.
var consumer = new ConsumerBuilder<string, string>(consumerConfig)
.SetPartitionsAssignedHandler((c, partitions) =>
{
// Fires when the group coordinator has just handed this
// consumer a set of partitions, before any message from them
// is delivered. 'partitions' has no offsets attached unless you
// supply your own here (e.g. to seek to a custom start point).
foreach (var tp in partitions)
Console.WriteLine($"Assigned {tp.Topic} [{tp.Partition}]");
})
.SetPartitionsRevokedHandler((c, partitions) =>
{
// Fires BEFORE partitions move to another member, while this
// consumer still legitimately owns them. This is your last safe
// chance to persist progress on work already completed.
try
{
c.Commit(partitions);
}
catch (KafkaException ex)
{
Console.WriteLine($"Commit during revoke failed: {ex.Error.Reason}");
}
})
.SetPartitionsLostHandler((c, partitions) =>
{
// Fires when ownership was already yanked away β a missed
// heartbeat, a session timeout, a network partition. By the
// time this runs, another consumer may already own these
// partitions, so committing here is unreliable and often
// pointless. Log it; don't rely on it to save you.
Console.WriteLine($"Lost {partitions.Count} partitions without a clean handoff");
})
.Build();
The distinction between revoked and lost is the one people miss. SetPartitionsRevokedHandler runs on the clean, negotiated path, under either assignment strategy β the consumer is still an active, healthy group member and is voluntarily giving up partitions as part of a coordinated rebalance. SetPartitionsLostHandler runs when the consumer has already fallen out of the group involuntarily (for example, its session expired) and is finding out about it after the fact. Anything you attempt to commit in the lost handler is racing against a coordinator that may have already reassigned those partitions to someone else, so treat it as a place to log and clean up local state, not a place to rely on for correctness. SetPartitionsAssignedHandler, by contrast, is where you find out what you're about to receive β useful for initializing per-partition state or, if you're doing something unusual like resuming from a custom offset store, for calling Assign with explicit TopicPartitionOffset values instead of letting the library use the committed offset or AutoOffsetReset.
Why Commit Inside the Revoked Handler
Here's the concrete failure this handler exists to prevent. Say consumer A owns partition 3, and the last offset it committed was 101 β meaning everything up to and including offset 100 is durably accounted for. It then consumes and successfully processes messages 101 through 105 β writing to a database, sending a notification, whatever the handler does β but hasn't committed yet because its commit logic only runs every N messages or every few seconds.
Consumer A owns partition 3, committed offset = 101
β
A processes offsets 101β105 (side effects already happened)
β
Consumer B joins the group β rebalance triggered
β
A's PartitionsRevokedHandler fires
β
If A commits offset 106 here:
Partition 3 β Consumer B
B resumes from offset 106
Messages 101β105 are NOT reprocessed
β
If A does NOT commit here:
Partition 3 β Consumer B
B resumes from offset 101 (last commit before revoke)
B reprocesses 101β105, duplicating every side effect A already did
Committing inside SetPartitionsRevokedHandler closes that gap. It's the last point in the partition's lifecycle on this consumer where a commit is guaranteed to be meaningful β the consumer still owns the partition, so the broker accepts the commit, and it happens synchronously as part of the handoff rather than racing against whatever timer or batch size threshold your regular commit logic uses. Without it, every rebalance silently replays however much work happened since the last scheduled commit, on top of whatever gap your regular commit cadence already tolerates.
β οΈ Common Mistake: Treating the revoked handler as optional because "we commit periodically anyway." Periodic commits reduce the average reprocessing window; the revoked handler collapses the rebalance-triggered reprocessing window to at most the single in-flight message, which is the one that happens exactly when partition ownership is changing hands β the worst possible time to also lose track of progress.
It's worth being precise about what this does and doesn't guarantee. Committing on revoke shrinks the reprocessing window down to, at most, the single message that was mid-flight at the instant the rebalance callback fired β it does not make reprocessing impossible. If your handler is mid-write when the revoke happens, that one message can still be picked up again by the new owner. That residual risk is exactly why the lesson on delivery guarantees treats duplicates as something to handle downstream rather than something offset timing alone can eliminate.
Cooperative-Sticky vs Eager: How Much the Rebalance Disrupts
How disruptive a rebalance is β how many partitions get yanked away, and from how many consumers at once β depends on the PartitionAssignmentStrategy configured on the consumer. Confluent.Kafka's underlying librdkafka client defaults to an eager strategy (range or round-robin assignment) unless you explicitly opt into PartitionAssignmentStrategy.CooperativeSticky in ConsumerConfig. The difference between the two isn't just an implementation detail β it changes how much of your consumer group stalls every time membership changes.
Under an eager strategy, any rebalance is stop-the-world for the entire group: every member revokes all of its currently owned partitions, the group reforms, and a brand-new assignment is computed and handed out from scratch. Your PartitionsRevokedHandler receives the full set of partitions you owned, even the ones you'll get right back a moment later, and no partition in the group is being consumed during the reassignment window.
Under cooperative-sticky, the assignor tries to keep as much of the existing assignment intact as possible and only revokes the specific partitions that genuinely need to move to accommodate the change. If a new consumer joins and only two partitions need to shift to it, the other members' PartitionsRevokedHandler calls receive an empty or near-empty set β they keep consuming their untouched partitions without interruption. This matters concretely for a service with, say, twelve partitions and four consumers: with eager assignment, scaling to a fifth consumer freezes all twelve partitions across all five members for the duration of the rebalance; with cooperative-sticky, only the handful of partitions that actually change owners are paused.
| Strategy | π Rebalance scope | βΈοΈ Disruption |
|---|---|---|
| Eager (range/round-robin) | π All partitions, all members | Full group pause |
| Cooperative-sticky | π― Only partitions that must move | Unaffected partitions keep flowing |
For most production consumer groups β especially ones that scale up and down with load or redeploy frequently β cooperative-sticky is the better default, because it turns routine membership churn from a full-group stall into a localized, partial one. The trade-off is that your revoked handler needs to be written to expect a variable, sometimes-empty set of partitions rather than assuming it always gets "everything I had."
The Silent Rebalance: max.poll.interval.ms and Slow Processing
The rebalance scenario people find most confusing is the one where the consumer process is clearly alive β it hasn't crashed, its network connection is fine, and yet the broker reassigns its partitions anyway. This happens because Kafka doesn't consider "the process is running" sufficient evidence that your application is making progress; it considers "the application is calling Consume and returning control in a timely manner" the real signal of health.
In Confluent.Kafka, librdkafka runs group heartbeats on an internal background thread, decoupled from the thread that calls Consume. That's a deliberate design so a slow message handler doesn't immediately look like a dead consumer to the broker. But there's a second, independent guard: max.poll.interval.ms, exposed on ConsumerConfig.MaxPollIntervalMs, which defaults to five minutes. This setting tracks the time between successive calls back into the consumer's poll loop, and if your application spends longer than that processing a single message (or a batch) before calling Consume again, librdkafka proactively has the consumer leave the group β triggering exactly the same rebalance machinery as a crash, even though the heartbeat thread was reporting the process as alive the entire time.
Consumer calls Consume() β gets message at offset 105
β
Handler starts a slow downstream call (e.g. a hung HTTP request)
β
Heartbeat thread: still sending heartbeats, broker sees consumer as alive
β
5 minutes pass, Consume() has not been called again
β
max.poll.interval.ms exceeded
β
librdkafka proactively leaves the group
β
PartitionsRevokedHandler fires β but the message at offset 105
is still mid-processing, so committing it here is not yet safe
β
Partition reassigned to another consumer, which starts from the
last successfully committed offset β likely reprocessing offset 105
This is the scenario the guidance about "process first, commit later" collides with directly: if a single message's processing time can exceed max.poll.interval.ms, you get an unwanted rebalance in the middle of legitimate work, and whichever consumer picks the partition up next will very likely redo that same message. Two practical levers exist. First, raise MaxPollIntervalMs to comfortably exceed your worst-case realistic processing time for whatever unit of work happens between Consume calls β but treat this as a ceiling for genuinely slow legitimate work, not a way to paper over a handler that's hanging indefinitely. Second, and usually the better fix, keep the work done per Consume call bounded β offload genuinely slow operations (a large batch write, a call to a flaky downstream service) so the loop returns to Consume frequently, rather than blocking it for minutes at a time.
π‘ Mental Model: max.poll.interval.ms isn't asking "is the process alive?" β the heartbeat thread already answers that. It's asking "is the application still cycling back to ask for more work?" A consumer stuck on one slow message answers no to the second question even while answering yes to the first, and Kafka rebalances based on the second answer.
Put together, these three mechanics β the assigned/revoked/lost handlers, the choice of assignment strategy, and the poll-interval watchdog β form the actual timing surface that determines whether a rebalance in your service is a clean handoff or a source of duplicate side effects. Getting the commit call placed inside PartitionsRevokedHandler, choosing cooperative-sticky for groups that scale or redeploy often, and sizing MaxPollIntervalMs to your real processing time are the three concrete levers that convert "rebalances happen sometimes" from a source of production incidents into a well-understood, bounded event.
From Auto-Commit to At-Least-Once: Seeing the Guarantees Play Out
Configuration flags like EnableAutoCommit don't just toggle a setting β they decide which failure mode you're willing to live with. The cleanest way to see this is to run the same crash at two different moments in the same message's lifecycle and watch the outcome flip from silent loss to harmless-if-handled duplicate. The mechanics are simple enough to trace by hand, and once you've traced them once, you can recognize the same pattern in any consumer code you read afterward β auto-commit versus manual, timer versus outcome, silence versus echo.
Scenario 1: Auto-Commit Fires on a Clock, Not on Success
With EnableAutoCommit = true, the underlying librdkafka client commits the offsets of messages it has already handed to your code on a fixed timer β controlled by auto.commit.interval.ms, which defaults to five seconds. That commit happens whether or not your handler has finished, thrown an exception, or even started. The client doesn't know or care what your business logic did; it only tracks what offsets have been delivered to your Consume() calls.
Walk through a concrete timeline for a billing service consuming an orders topic:
T+0.0s Consume() returns the message at offset 100 (a $500 charge request)
T+0.5s Handler begins: call external payment gateway
T+5.0s Auto-commit interval elapses -> client commits offset 101
(offset 100 is now considered "done" from Kafka's point of view,
even though the payment call hasn't returned yet)
T+6.2s Payment gateway call is still in flight when the process crashes
(out-of-memory kill, container eviction, unhandled exception in
a background thread β the cause doesn't matter)
On restart, the consumer asks Kafka "where did I leave off?" and gets back offset 101, because that's what was last committed. The message at offset 100 is gone from this consumer's perspective β it will never be redelivered to this group. If the payment call had not actually completed before the crash, that $500 charge simply never happened, and nothing in the system will tell you. This is the at-most-once shape: the commit happened before (or independently of) completion, so a crash can leave work permanently undone with no signal.
It's worth tracing the same setup with the crash moved slightly earlier, because it shows why this bug is so easy to miss in testing:
T+0.0s Consume() returns the message at offset 100
T+0.5s Handler begins: call external payment gateway
T+4.2s Process crashes β before the 5.0s auto-commit tick has fired
Here, offset 100 was never committed, so on restart it's redelivered and reprocessed correctly. Nothing looks wrong. The only difference between this run and the earlier one is a two-second shift in when the crash happened relative to an invisible timer β and that timer is running independently of your code, your tests, and your intuition about "how long processing usually takes." A handler that's fast in staging and slow under production load (a saturated payment gateway, a cold connection pool, a GC pause) can silently cross from the safe case into the loss case without any code change at all. The interval doesn't need to be short for this to bite β it only needs to fire once between "message received" and "work actually finished."
Auto-commit has a second, less obvious trigger worth knowing about even though it's outside this lesson's focus on the manual-commit call sequence: a consumer group rebalance can also cause pending offsets to be committed as partitions are revoked, on whatever schedule the client considers "last known delivered." The exact mechanics differ from the interval timer, but the shape of the risk is identical β an offset can advance based on delivery, not completion. Whenever you see EnableAutoCommit = true, the question to ask is always the same one: "what moment does this commit correspond to, and is that moment guaranteed to be after my side effect finished?" If the answer is "no" or "I'm not sure," you're looking at at-most-once risk, regardless of how the commit was actually triggered.
β οΈ Common Mistake: Treating auto-commit as "probably fine because it commits infrequently." As the two timelines above show, infrequent commits don't shrink the danger β they just shrink how often you'll notice it in testing, while leaving the exposure fully intact in production under realistic latency.
Scenario 2: Commit Only After Success
Now rerun the identical crash, but with EnableAutoCommit = false and a handler that commits only once the payment call has returned successfully (the exact StoreOffset/Commit call sequence for doing this correctly is covered in the dedicated manual commit pattern lesson β here we're only tracing the outcome of committing after success).
T+0.0s Consume() returns the message at offset 100 (a $500 charge request)
T+0.5s Handler begins: call external payment gateway
T+2.1s Payment gateway confirms the charge succeeded
T+2.3s Process crashes before the commit call executes
Step through what happens on restart exactly the way the consumer would: it asks the broker for the last committed offset for this group and partition, gets back 100 (the commit that would have advanced it to 101 never happened), and resumes there. Kafka redelivers offset 100 β same key, same value, same partition, same offset number as the first delivery. The handler runs top to bottom again: deserialize the order, call the payment gateway, and this time the gateway call succeeds a second time, because from the gateway's point of view this looks like a brand-new charge request. Picture the ledger this produces:
Charge attempt #1 (first delivery, T+2.1s): order-4471 charged $500, gateway ref A1B2
Charge attempt #2 (redelivery, T+9.6s): order-4471 charged $500, gateway ref C3D4
Nothing was lost β the order was never silently skipped β but the customer now has two $500 charges tied to one order. This is the at-least-once shape: because you commit only after the work is done, a crash can never cause the offset to advance past unfinished work, so nothing silently disappears. The price is that the same message can be processed more than once.
It's also worth noting that "crash before commit" isn't the only path to this outcome. If the commit call is sent but the process dies (or the network stalls) before the broker's acknowledgment comes back, the client has no way to know whether the commit landed β so on restart it must, correctly, assume it didn't and reprocess from the last known-good committed offset. This doesn't change the guarantee at all; it's still at-least-once, and it's still handled the same way. It's mentioned here only to make the point concrete: there are more real-world timings that produce a redelivery than just "the crash happened one line before the commit statement." Anywhere completion and checkpointing are two separate steps, a gap exists, and that gap is what makes redelivery possible.
Same Crash, Two Outcomes
Both scenarios crash at roughly the same wall-clock moment relative to the payment call. The only variable is when the offset was allowed to move.
| β±οΈ Commit timing | π₯ Crash mid-flight | π On restart | π Result |
|---|---|---|---|
| π Commit on a timer, independent of outcome | Offset already advanced past unfinished work | Resumes past the lost message | Message effectively skipped β at-most-once |
| β Commit only after handler succeeds | Offset never advanced because work wasn't marked done | Resumes at the same message, redelivers it | Message reprocessed β at-least-once |
π― Key Principle: Delivery semantics aren't a property of Kafka the broker β they're a property of when your consumer chooses to move its checkpoint relative to when the work actually happened. Kafka will faithfully redeliver anything you haven't committed; whether that's a feature or a bug depends entirely on whether your commit was honest about what got done.
Recognizing the Regime You're In
Before moving on, it helps to fix a quick recognition habit, because in real codebases the configuration and the commit call are often far apart β a flag set in startup configuration, a commit buried inside a message loop dozens of lines later. Two questions settle it every time:
- Is
EnableAutoCommittrue or false? If true, stop there β you're in at-most-once-risk territory regardless of what the handler code looks like, because the commit is on a clock the handler doesn't control. - If false, where does the commit call sit relative to the side effect? If it's textually and logically after the point where the work is confirmed done (a returned success, a completed database write, a confirmed API response), you're in at-least-once territory. If a commit call appears before that confirmation β even "by accident," such as committing right after
Consume()returns instead of after the handler runs β you've reintroduced at-most-once risk manually, without the auto-commit flag anywhere in sight.
That second trap is worth calling out explicitly: at-most-once isn't something only EnableAutoCommit = true can cause. Any code that advances the checkpoint before the work is verifiably finished has the same shape as Scenario 1, whether or not a timer is involved.
At-Least-Once Is Not the Same as "Safe"
It's tempting to treat "commit after success" as the finish line β you've avoided losing data, so surely you're done. But at-least-once only makes one promise: no message you actually finished processing will be silently dropped. It makes zero promise about how many times that processing might run. Scenario 2 above didn't just illustrate redelivery in the abstract β it showed a real duplicate side effect: the payment gateway getting called twice for one order, with two distinct gateway references and two distinct charges landing on the same customer. If your handler's side effect isn't safe to repeat, at-least-once quietly converts "we never lose an order" into "we sometimes double-charge an order," which is not an obviously better trade for the customer on the receiving end.
The redelivered message is byte-for-byte identical to the first delivery β same key, same value, same offset. Kafka has no concept of "this one already happened once elsewhere"; that bookkeeping is entirely your responsibility. This is why idempotent processing isn't an optional polish step bolted on later β it's the other half of the at-least-once contract, and without it you haven't actually achieved a safe guarantee, just relocated the risk from "lost message" to "duplicate side effect."
It's also worth being precise about what "handling" a duplicate actually requires: it's not enough to detect that a message looks like one you've seen before, because two different orders can legitimately share every field except an ID, and two genuinely distinct deliveries of the same order need to collapse into a single effect even if they arrive far enough apart that no in-memory cache would still remember the first one. That's why the fix has to live at the level of durable, atomic state β a database constraint, not a retry-loop guard or an in-process set of "recently seen" IDs that a restart would silently wipe.
A minimal way to make a handler idempotent is to give each unit of work a durable identity and check-and-record that identity atomically with the side effect, so a second attempt becomes a no-op instead of a repeat:
// Idempotent handler: the OrderId doubles as a natural deduplication key.
// A unique constraint on order_id turns a duplicate delivery into a no-op
// instead of a second charge.
async Task HandleOrderCreatedAsync(ConsumeResult<string, string> result, CancellationToken ct)
{
var order = JsonSerializer.Deserialize<OrderCreated>(result.Message.Value)!;
// Try to record this order as processed. If a row with this order_id
// already exists (because this is a redelivery), the insert affects
// zero rows instead of throwing or duplicating.
var insertedRows = await _db.ExecuteAsync(
"""
INSERT INTO processed_orders (order_id, amount, processed_at)
VALUES (@OrderId, @Amount, now())
ON CONFLICT (order_id) DO NOTHING
""",
new { order.OrderId, order.Amount });
if (insertedRows == 0)
{
// Already processed on a previous delivery attempt β skip the
// side effect entirely rather than repeating it.
_logger.LogInformation(
"Duplicate delivery for order {OrderId}, skipping charge", order.OrderId);
return;
}
await _paymentGateway.ChargeAsync(order.OrderId, order.Amount, ct);
}
The unique constraint is doing the real work here: it's what makes the check-then-act sequence safe even if two redeliveries somehow raced each other, because the database β not your application code β is the arbiter of "has this already happened." Trace it through the race explicitly: if two copies of this handler run at nearly the same instant (say, one instance finishing a slow shutdown while a newly started instance picks up the same unacknowledged offset), both attempt the INSERT, but the database guarantees only one of the two can win the unique constraint β the loser gets zero affected rows and skips the charge, exactly as if it had arrived seconds later instead of milliseconds later.
Not every side effect fits an "insert a new row" pattern, though. For a side effect like decrementing inventory, there is no natural row whose insertion is the side effect, but the same principle β let the database be the arbiter β still applies: insert a claim row, let it gate the update, and put both inside one transaction so they succeed or fail together:
// Idempotent inventory decrement: the adjustment row is the arbiter, and
// both statements run in one transaction so a crash can't split them.
using var tx = await connection.BeginTransactionAsync();
var claimed = await _db.ExecuteAsync(
"""
INSERT INTO inventory_adjustments (order_id, sku)
VALUES (@OrderId, @Sku)
ON CONFLICT (order_id, sku) DO NOTHING
""",
new { order.OrderId, order.Sku }, tx);
if (claimed > 0)
{
await _db.ExecuteAsync(
"UPDATE inventory SET quantity = quantity - @Quantity WHERE sku = @Sku",
new { order.Sku, order.Quantity }, tx);
}
await tx.CommitAsync();
A dedup table like processed_orders, an idempotency key on an outbound API call, or a guarded update like the one above are the usual tools; which one fits depends on where the side effect actually lives β inside your own database, or on the far side of a third-party API you don't control.
Deciding Where Duplicates Are Tolerable
Not every handler needs this machinery, and reaching for a dedup table on every consumer is wasted effort in places where a duplicate genuinely doesn't matter. The decision cue is simple: ask what happens if this exact handler runs twice for the same message, back to back, with no changes in between.
| π§ Side effect | π Duplicate impact | π§ Typical treatment |
|---|---|---|
| π Incrementing an in-memory metrics counter | Permanently inflated count β nothing corrects it | Tolerate β low stakes, not self-healing |
| π₯ Refreshing a read-through cache entry | Same value written twice | Tolerate β writes are naturally idempotent |
| π³ Charging a payment method | Customer billed twice β real financial harm | Dedup key + unique constraint required |
| π¦ Decrementing inventory count | Stock count wrong, oversells possible | Idempotent upsert or dedup key required |
| βοΈ Sending a one-time confirmation email | Customer gets two emails β annoying, not harmful | Judgment call; often tolerated, sometimes deduped |
The pattern underneath the table: side effects that are naturally idempotent by construction β setting a value, upserting a row to the same state, overwriting a cache key β can usually skip explicit dedup logic, because running them twice produces the same end state as running them once. Side effects that are inherently additive or one-shot β charging money, sending exactly one email, decrementing a counter, calling a non-idempotent third-party API β need an explicit dedup key precisely because "twice" and "once" produce genuinely different outcomes.
π‘ Mental Model: Ask "does running this twice change the state, or does it change the count of times something happened?" State-setting operations (upserts, overwrites) tend to be naturally duplicate-safe. Event-counting operations (charge, send, decrement) are not, and need a guard.
Two quick worked examples show the cue in action. First: a handler that sends a "your order has shipped" push notification. Run it twice back to back and the customer's device shows the notification twice β mildly annoying, but the state of the world (the order is shipped, the customer knows it) is unchanged either way. That's a count-of-occurrences side effect, but a low-stakes one, so most teams tolerate it rather than build dedup infrastructure for it. Second: a handler that applies a one-time $10 signup credit to a customer's account by running UPDATE accounts SET balance = balance + 10 WHERE customer_id = @Id. Run that twice and the customer now has $20 of credit, $10 of which they were never supposed to receive β this looks like a state update syntactically, but semantically it's additive and one-shot, so it needs the same guard as the payment example: a credits_applied table keyed on (customer_id, promotion_id) with a unique constraint, checked before the balance update runs. The lesson from comparing these two is that you can't tell which bucket a handler falls into from its SQL shape alone β UPDATE ... SET x = x + n is additive no matter how it's phrased, while UPDATE ... SET x = n is a genuine overwrite. Reading the side effect for whether repeating it changes an accumulated total is the reliable test, not skimming the verb.
Seen side by side, the two crash scenarios reduce to one operating rule: commit timing determines whether a crash produces silence or an echo, and for most business services the echo β paired with an idempotent handler β is the far safer failure to design for.
Common Pitfalls and Pre-Production Checklist
Every one of the mistakes below is a real, recurring shape of failure in raw Confluent.Kafka consumers β not exotic edge cases, but the default outcome of copying a tutorial's config block and never revisiting it once the service is under load. None of them throw a compile error. All of them look fine in a demo. They surface weeks later as "we lost some orders" or "why did billing run twice."
Mistake 1: EnableAutoOffsetStore Left On While You Think You're Fully Manual
The section on consumer configuration explained what EnableAutoCommit and EnableAutoOffsetStore each disable internally. The pitfall is simpler than the mechanism: developers set EnableAutoCommit = false, see that auto-commit is gone, and assume they now have full manual control over offsets. They don't β because EnableAutoOffsetStore defaults to true in Confluent.Kafka's ConsumerConfig, mirroring the underlying librdkafka default of enable.auto.offset.store=true.
With that default left in place, librdkafka stores the offset of every message internally the moment Consume() returns it to your code β before your handler has done anything with it. EnableAutoCommit = false only stops that stored offset from being committed to the broker on a timer. If you later call consumer.Commit() anywhere in your code β during shutdown, in a periodic housekeeping task, or even accidentally in a retry path β it will commit whatever was auto-stored, which may be far ahead of what you actually finished processing.
// β Wrong thinking: "I disabled auto-commit, so I have manual control."
var consumerConfig = new ConsumerConfig
{
BootstrapServers = "localhost:9092",
GroupId = "billing-service",
EnableAutoCommit = false
// EnableAutoOffsetStore left at its default (true) β offsets are
// still being staged internally on every Consume() call, unprocessed.
};
// β
Correct thinking: both flags must be off for true manual control.
var consumerConfig = new ConsumerConfig
{
BootstrapServers = "localhost:9092",
GroupId = "billing-service",
EnableAutoCommit = false,
EnableAutoOffsetStore = false
// Now nothing is stored or committed until your code explicitly
// calls consumer.StoreOffset(...) after successful processing.
};
The failure mode this produces is quiet: the service looks like it's doing manual commits (because you wrote a Commit() call somewhere), but the actual offset being committed was already staged ahead of processing. A crash between staging and your intended commit point can silently advance past unprocessed messages β the exact at-most-once risk manual commits were supposed to eliminate.
π‘ Pro Tip: If you're not sure whether both flags are set, log the resolved ConsumerConfig values at startup. A config typo here doesn't fail loudly; it fails quietly, months later, during an incident review.
Mistake 2: Catching ConsumeException Without Logging Where It Happened
The loop-building section covers how to distinguish transient from fatal errors via Error.IsFatal. The pitfall here is a habit that survives that correct logic: catching the exception, logging a generic message, and moving on β without capturing the coordinates of the failure.
// β Wrong thinking: "I logged the exception, so I have visibility."
try
{
var result = consumer.Consume(cancellationToken);
ProcessMessage(result);
}
catch (ConsumeException ex)
{
_logger.LogError(ex, "Error consuming message");
// Which topic? Which partition? Which offset? Which key?
// This tells an on-call engineer almost nothing at 2 AM.
}
A ConsumeException in Confluent.Kafka carries a ConsumerRecord on its .ConsumerRecord property in many failure cases (for example, deserialization failures), which exposes Topic, Partition, and Offset even though the message failed to deserialize. Losing that context means the only way to find the offending message later is to replay the whole partition and guess β effectively archaeology with a spoon, one offset at a time.
// β
Correct thinking: always log the coordinates that let you find
// the exact message again, even when the message body is unusable.
catch (ConsumeException ex)
{
var record = ex.ConsumerRecord;
_logger.LogError(ex,
"Consume failed. Topic={Topic} Partition={Partition} Offset={Offset} " +
"IsFatal={IsFatal} Reason={Reason}",
record?.Topic,
record?.Partition.Value,
record?.Offset.Value,
ex.Error.IsFatal,
ex.Error.Reason);
if (ex.Error.IsFatal)
{
throw; // stop the loop; this consumer instance can no longer make progress
}
// transient error: loop continues, next Consume() call will retry
}
The same discipline applies to a successfully deserialized message that fails in your own handler logic β log Message.Key, Partition, and Offset alongside the exception before routing to retry or DLQ. Without topic, partition, offset, and key on every failure log line, you cannot correlate a customer complaint ("my order didn't go through") with a specific Kafka record, and you cannot safely decide whether to replay, skip, or manually seek.
Mistake 3: Letting AutoOffsetReset Default to Latest on a New Group in Production
The configuration section covers what Earliest, Latest, and Error each mean for a group with no committed offsets. The production pitfall is not misunderstanding that behavior β it's never deciding it at all, and inheriting the librdkafka default of auto.offset.reset = latest.
Consider a concrete scenario: a team ships a new consumer group, inventory-sync-v2, to replace an older group. On first startup, the group has no committed offsets for any partition of the orders topic. With AutoOffsetReset left unset (defaulting to Latest), the new consumer starts reading only from the tail of the log β every order produced before the moment this consumer connected is skipped entirely, with no error, no warning, and no gap visible in the logs. The consumer looks healthy. Lag looks fine. The only symptom is a business one: inventory counts silently stop matching orders placed earlier that day.
// β Wrong thinking: "I didn't set AutoOffsetReset, so Kafka will pick something safe."
var consumerConfig = new ConsumerConfig
{
BootstrapServers = "localhost:9092",
GroupId = "inventory-sync-v2",
EnableAutoCommit = false,
EnableAutoOffsetStore = false
// AutoOffsetReset unset β defaults to Latest β backlog is skipped
// on first run, silently, for a brand-new group.
};
// β
Correct thinking: choose the reset policy deliberately, based on
// whether this consumer needs history or only needs "from now on".
var consumerConfig = new ConsumerConfig
{
BootstrapServers = "localhost:9092",
GroupId = "inventory-sync-v2",
EnableAutoCommit = false,
EnableAutoOffsetStore = false,
AutoOffsetReset = AutoOffsetReset.Earliest
};
This only matters for a new group, or a group whose committed offsets have expired past the topic's retention window β once offsets are committed, AutoOffsetReset has no effect on normal restarts. But "new group" happens more often in practice than it sounds: renaming a GroupId during a refactor, standing up a parallel version of a service for a migration, or spinning up a consumer for a new downstream feature all create a brand-new group with no history, and each of those moments is exactly when a silent Latest default costs you a backlog you didn't know you were dropping.
Mistake 4: Long Synchronous Work Inside the Poll Loop
Rebalance handlers and max.poll.interval.ms are covered in depth in the section on partition assignment. The pitfall to recognize here is a specific, common shape of code: doing slow, blocking work directly inside the loop between calls to Consume().
// β Wrong thinking: "heartbeats are handled in the background, so
// slow processing in my loop is safe."
while (!cancellationToken.IsCancellationRequested)
{
var result = consumer.Consume(cancellationToken);
// Blocking, synchronous call to a slow downstream system β
// no timeout, no async, potentially minutes under load.
var invoiceResponse = billingClient.ChargeCustomerBlocking(result.Message.Value);
consumer.StoreOffset(result);
consumer.Commit();
}
In Confluent.Kafka, heartbeats to the group coordinator are indeed sent from a background thread, so the connection to the broker doesn't visibly drop while this loop is busy. But the consumer group protocol still requires the application to call Consume() again within max.poll.interval.ms (default measured in minutes, but very achievable to exceed under load or a downstream outage). If your synchronous work inside the loop runs longer than that window, librdkafka proactively sends a "leave group" on your behalf β even though heartbeats kept flowing β and the coordinator reassigns your partitions to another member.
Here is what that produces end to end:
Consume() returns message at offset 104
β
Handler starts a slow, blocking charge call
β
Time exceeds max.poll.interval.ms
β
Group coordinator marks this member as having left the group
β
Partition reassigned to another consumer instance
β
Original handler eventually finishes charging the customer
β
Original consumer tries to commit offset 105 β but it no longer owns
the partition, so the commit is rejected or ignored
β
New owner starts from the last successfully committed offset (104),
re-reads offset 104, and charges the customer again
The result is a duplicate side effect β a double charge β that has nothing to do with a crash or a network blip. The consumer process never went down; it just took too long between polls, and the rebalance machinery correctly assumed it was dead. This is why the recommended shape keeps the loop itself light: Consume(), hand the message to bounded async work (or a fast, non-blocking call), and return to Consume() promptly, tuning max.poll.interval.ms upward only as a deliberate accommodation for known-slow work rather than as a blanket fix for unbounded processing time.
Pre-Production Checklist
Those four mistakes span three parts of the consumer's lifecycle β configuration, loop error handling, and rebalance timing β which is exactly why they're easy to miss individually even when each piece looks correct in isolation. Before relying on manual commits and graceful shutdown in production, walk through all three areas together.
| π§ Area | β Check | Why it breaks otherwise |
|---|---|---|
| π§ Configuration | EnableAutoCommit=false AND EnableAutoOffsetStore=false, both set explicitly | Offsets get staged before processing finishes |
| π§ Configuration | AutoOffsetReset chosen deliberately per GroupId, not left at default | New/renamed groups silently skip backlog |
| π Loop | Every catch block logs Topic, Partition, Offset, Key | Failures become undiagnosable without replay |
| π Loop | Fatal vs transient errors branch differently (stop vs retry) | Loop either crashes needlessly or spins on a dead broker |
| βοΈ Rebalance | No unbounded synchronous work between Consume() calls | Exceeding max.poll.interval.ms triggers surprise rebalances |
| βοΈ Rebalance | PartitionsRevokedHandler commits in-flight progress before giving up ownership | Otherwise the next owner redoes work needlessly |
β οΈ Common Mistake: treating this as a one-time setup checklist rather than a code-review checklist. Config drifts (someone "simplifies" the ConsumerConfig during a refactor), new handlers get added to the loop without anyone checking their worst-case latency, and GroupId gets changed during a migration without anyone re-deciding AutoOffsetReset. Re-run this checklist whenever any of those three areas changes, not just before the first deployment.
π‘ Real-World Example: a service that processes payment webhooks moved its handler from a fast in-memory validation to a synchronous call against a fraud-scoring API. Nothing else in the code changed. Under normal load the call returned in milliseconds; under a fraud-provider slowdown it occasionally took long enough to exceed the poll interval, triggering rebalances and duplicate charge attempts that only idempotent downstream logic prevented from reaching the customer twice. The lesson isn't "never call external services from a handler" β it's that any change to per-message processing time needs to be checked against max.poll.interval.ms, not assumed safe because it was fast yesterday.
With configuration, loop error handling, and rebalance timing checked together, you've closed the gaps that don't show up in a demo but reliably show up in production: offsets committed ahead of processing, failures nobody can trace back to a specific record, backlogs silently skipped by a new group, and duplicate side effects from rebalances that had nothing to do with a crash. As a next step, run the crash-before-commit test and the two-consumer rebalance test against your actual ConsumerConfig and handler code β not a simplified test double β since several of these pitfalls (the offset-store flag in particular) only manifest under those exact conditions.