Kafka Reliability and Exactly-Once Semantics

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Kafka’s reliability story is not one setting — it is several independent levers that only add up to a guarantee when they are configured together, several of which are covered individually elsewhere on this site: replication and the in-sync replica set on Apache Kafka Architecture, acks/min.insync.replicas/idempotence on Kafka Producers, manual offset commits on Kafka Consumers, and the at-most-once/at-least-once/exactly-once vocabulary itself on Delivery Guarantees. This page is the synthesis: it names the handful of configuration decisions that turn "replicated and idempotent" into "safe to lose a broker without losing or duplicating data," then goes deep on the one mechanism none of those pages cover in full — transactions, the feature that actually delivers exactly-once semantics across a multi-topic consume-transform-produce pipeline — and closes with the one durability caveat every one of those levers still has to live with: Kafka does not fsync to disk by default.

The reliability levers, tied together

Replication factor and unclean leader election

A topic’s replication factor sets how many broker copies of each partition exist; the ISR mechanics that keep those copies in sync, and how a new leader is chosen when the current one fails, are covered in full on Apache Kafka Architecture. One choice from that mechanism belongs specifically in a reliability discussion: the topic configuration unclean.leader.election.enable (default false) decides what happens if every in-sync replica of a partition becomes unavailable at once. Left at its default, Kafka refuses to elect a leader from outside the ISR — the partition simply stays unavailable for writes and reads until a former in-sync replica comes back, which guarantees no acknowledged data is ever silently lost. Setting it to true instead lets a broker outside the ISR become leader as a last resort, trading that guarantee for availability: the partition keeps serving traffic, but whatever the new leader never replicated is gone. Kafka’s own eligible-leader-replicas mechanism (also covered on Apache Kafka Architecture) narrows how often this trade-off actually has to be made, by widening the pool of safe, data-preserving candidates the controller can pick from before it would ever need unclean election — but it does not remove the setting itself, which remains the last line of defense between "unavailable" and "lossy."

min.insync.replicas and acks=all

The producer-side durability guarantee — acks=all plus min.insync.replicas rejecting writes outright if the ISR has shrunk too far to honor them — is covered in full, including the exact interaction between the two settings, on Kafka Producers and again from the delivery-semantics angle on Delivery Guarantees. The short version for this page’s purposes: with a replication factor of 3 and min.insync.replicas=2, an acks=all write survives the loss of any single broker without loss or without the producer ever believing an unreplicated write succeeded — raising min.insync.replicas further trades some availability (fewer tolerable simultaneous broker failures) for a stronger floor on how many copies a committed write is guaranteed to have.

Producer retries and idempotence

enable.idempotence (default true since Kafka 3.0) is what makes the producer’s own network retries safe — without it, a retried send after an ambiguous timeout can create a duplicate record at the log level, on top of whatever duplicates a crashing application creates. The full mechanism (producer IDs, sequence numbers, delivery.timeout.ms) is covered on Kafka Producers; the point worth restating here is that idempotence only removes producer-retry duplicates, not application-level duplicates from a producer process that crashes and resends the same logical record from scratch — transactions, below, are what closes that remaining gap.

Consumer commit discipline

Symmetrically, on the consumer side, Kafka Consumers covers commitSync()/commitAsync() and why committing only after processing finishes (rather than before) is the consumer half of at-least-once delivery. Reliability end to end requires both halves configured for durability at once — an idempotent acks=all producer feeding a consumer that commits before processing still loses work on a consumer crash, and a careful manual-commit consumer reading from a producer with acks=1 still loses records on a leader failure. Neither side’s discipline substitutes for the other’s.

Transactions and exactly-once semantics

Idempotence and careful commit ordering get an application to at-least-once, with the burden of deduplication or idempotent processing logic left to the application. Transactions (KIP-98, generally available since Kafka 0.11) go further: they let a producer commit a set of writes — to one or several topic-partitions — together with an update to a consumer group’s committed offsets, atomically, as a single unit that either becomes fully visible or leaves no trace at all. This is what actually delivers exactly-once semantics for the common consume-transform-produce pattern: read from one topic, transform, write to another, and commit the read offset, without ever double-processing or silently dropping a record across a crash in the middle.

The transactional producer API

A transactional producer is configured with a stable transactional.id and driven through four calls:

Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "order-enrichment-1");
// acks=all and enable.idempotence=true are implied/required by a transactional.id

KafkaProducer<String, String> producer = new KafkaProducer<>(props);
producer.initTransactions(); // once, at startup -- fences off any prior instance with the same transactional.id

while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
    if (records.isEmpty()) {
        continue;
    }
    producer.beginTransaction();
    try {
        for (ConsumerRecord<String, String> record : records) {
            producer.send(new ProducerRecord<>("orders-enriched", record.key(), enrich(record.value())));
        }
        // the read offsets become part of the same atomic unit as the writes above
        producer.sendOffsetsToTransaction(currentOffsets(records), consumer.groupMetadata());
        producer.commitTransaction();
    } catch (Exception e) {
        producer.abortTransaction(); // undoes the writes and the offset update together; nothing is half-applied
    }
}

initTransactions() runs once at startup and, critically, fences any earlier producer instance still running under the same transactional.id — the classic "zombie" scenario where a slow or partially-hung old instance is still trying to write after a new one has taken over. beginTransaction() opens a transaction, sendOffsetsToTransaction(Map, ConsumerGroupMetadata) folds the consumer’s offset commit into that same transaction instead of committing it separately, and commitTransaction()/abortTransaction() close it out — committed writes and the offset update become visible together, or an abort discards both, so a crash between "produced the transformed record" and "committed the read offset" can never leave the pipeline having produced output for a record it will also redeliver and reprocess. On the read side, isolation.level=read_committed (covered on Delivery Guarantees) is what makes a downstream consumer skip the writes of a transaction that was aborted, or is still open, rather than seeing them immediately.

The sequence below draws one iteration of this loop — a transaction spanning a consumer’s offset commit and a producer’s writes to a downstream topic.

sequenceDiagram participant C as Consumer (source topic) participant P as Transactional Producer participant B as Brokers (source-offsets + orders-enriched) participant DC as Downstream Consumer (read_committed) C->>P: poll() records from "orders" P->>B: beginTransaction() P->>B: send() transformed records to "orders-enriched" P->>B: sendOffsetsToTransaction(offsets, group metadata) P->>B: commitTransaction() B-->>B: write transaction marker (COMMIT) DC->>B: poll() "orders-enriched" (isolation.level=read_committed) B-->>DC: transformed records now visible (marker seen) Note over P,B: a crash before commitTransaction() leaves nothing visible -- abortTransaction()\nor a fenced-out zombie produces the same outcome: no partial output, no silently\nskipped offset commit

Transactions v2 (KIP-890, Kafka 4.0)

Starting in Kafka 4.0, a strengthened transactional protocol — transactions v2, delivered by KIP-890 — is enabled by default on the broker (transaction.version=2) and used automatically by 4.0+ producer clients. Its core change is bumping the producer’s epoch on every transaction rather than reusing one across many transactions, which closes a narrow but real gap in the original protocol where a producer that crashed and restarted mid-transaction could, in rare timing windows, have some of its intended writes committed as part of the next transaction instead of being cleanly aborted with the one they belonged to. The new protocol also collapses what used to be a separate client-side "add partition" call and server-side "verify" round trip into a single server-side call, which is a latency and throughput improvement on top of the correctness fix; a cluster running 4.0+ negotiates this automatically; older, pre-4.0 clients keep working against it under the previous protocol.

What exactly-once does and does not cover

Kafka’s transactions guarantee exactly-once within Kafka — across the topics and consumer-group offsets a single transaction touches. They do not, and cannot, extend that guarantee to a side effect a transaction’s processing logic has on a system Kafka does not control: writing to an external database, calling a downstream HTTP API, or sending an email inside the same loop is not covered by commitTransaction(), and a crash after that side effect but before the commit (or the reverse) can still duplicate or lose that external effect even though the Kafka-side state stays perfectly exactly-once. Making an external side effect participate in the same guarantee needs its own idempotency or transactional-outbox design on the external system’s side, entirely independent of anything a transactional.id provides.

The durability caveat: Kafka does not fsync by default

None of the levers above change one underlying fact, already introduced on Delivery Guarantees: an acks=all commit means every in-sync replica has the record in its log, which in the common case means the OS page cache, not necessarily a disk-flushed (fsync) write. Two topic-level configurations exist to force an fsync: flush.messages (force a flush every N messages) and flush.ms (force a flush every N milliseconds); both default to effectively "never" (Long.MAX_VALUE), and the broker-level log.flush.interval.messages/log.flush.interval.ms set the cluster-wide defaults they inherit. Kafka’s own documented recommendation is to leave both alone and rely on replication instead: forcing a synchronous fsync on every write serializes every producer behind physical disk latency, while the OS’s own background flushing already writes dirty pages out efficiently on its own schedule. The practical mitigation for the data a page-cache-only write could still lose in a total power failure is not tuning flush.messages down to 1 — it is what min.insync.replicas and replication factor already buy: spreading replicas across failure domains (racks, availability zones) so that the same power event is unlikely to take out every in-sync replica’s page cache simultaneously. The figure below shows the specific failure mode min.insync.replicas exists to reject outright rather than silently risk: a three-replica partition whose ISR has shrunk to one member.

Diagram of a Kafka partition with replication factor 3 and min.insync.replicas=2 showing the in-sync replica set shrinking from all three replicas to a single surviving in-sync replica as two followers fall out of sync — an acks=all producer write is then rejected with NotEnoughReplicasException because the ISR size of 1 is below min.insync.replicas of 2