Kafka Delivery Semantics

At-most-once, at-least-once and exactly-once, with the settings that actually produce each. Exactly-once is a property of a pipeline rather than of a producer, and the consumer setting that completes it is the one most often missed.

14 entries. Use your browser's find, Ctrl+F or Cmd+F, to jump to one.

At most once

Records may be lost and are never duplicated. Almost nobody chooses this deliberately, and a great many people have it by accident, because it is what the consumer's default auto-commit gives you.

How you get it by accident

enable.auto.commit=true

The consumer commits offsets on a timer, every auto.commit.interval.ms, whether or not the records in that range have been processed. If the process dies after a commit and before the work finishes, those records are never reprocessed. They are simply gone, and lag reads zero throughout.

What to do: If losing records is not acceptable, set enable.auto.commit=false and commit after the work has actually succeeded. This is the single most common correctness bug in Kafka consumers, and it is invisible in every metric.

enable.auto.commit=false
# then, only after the work succeeded:
consumer.commitSync();

On the producer side

acks=0

The producer does not wait for any acknowledgement. A record lost in the network, or arriving at a broker that is not the leader, is never retried because the producer never learns. Throughput is the highest available and the loss is silent.

What to do: Only correct where loss genuinely does not matter, which in practice means metrics and sampled telemetry. Note that acks=0 is incompatible with idempotence and the producer fails to start if both are set.

At least once

Records are never lost and may be duplicated. This is the default posture since 3.0 and it is the right answer for most systems, provided the consumer is idempotent.

The producer half

acks=all with retries

The producer waits for min.insync.replicas to acknowledge, and retries on a retriable error. A retry after an acknowledgement that was sent but not received produces a duplicate, which is the whole reason this is not exactly-once.

What to do: acks=all is the default since 3.0. Set min.insync.replicas=2 on a replication-factor-3 topic: with the default of 1, acks=all waits for the leader alone and a leader failure still loses acknowledged data.

# topic side, and this is the half people forget
kafka-configs.sh --bootstrap-server broker:9092 \
  --alter --entity-type topics --entity-name orders \
  --add-config min.insync.replicas=2

The consumer half

commit after processing

Process the batch, then commit. A crash between the work and the commit means the batch is processed again on restart, so downstream has to tolerate a repeat. That tolerance is where the real design work is.

What to do: Make the downstream effect idempotent: an upsert keyed on something in the record, or a de-duplication table keyed on topic, partition and offset. This is more robust than transactions and works with systems that have no transaction support at all.

delivery.timeout.ms bounds the whole thing

Since 2.1 this is the total time a send may take, retries included, and it supersedes reasoning about retries and retry.backoff.ms separately. When it expires the send fails permanently whatever retries is set to.

What to do: Set delivery.timeout.ms to how long the application can tolerate a send taking, and leave retries at its default of Integer.MAX_VALUE. Tuning retries directly is the old way and gives a bound nobody can compute.

delivery.timeout.ms=120000
request.timeout.ms=30000
retries=2147483647

Exactly once, and what it actually covers

The phrase covers three different things and only the third is what people usually mean. Getting the difference wrong is the commonest expensive mistake in this area.

Idempotent producer

enable.idempotence=true

Deduplicates retries within one producer session, per partition. The producer gets a producer id and a sequence number per partition, and the broker rejects a duplicate sequence. This removes duplicates caused by retries and nothing else. It does NOT survive a producer restart: a new session gets a new producer id.

What to do: It is the default since 3.0 and costs almost nothing, so leave it on. Note that it requires acks=all, max.in.flight.requests.per.connection at most 5, and retries above 0, and the producer refuses to start if you contradict any of them.

enable.idempotence=true
acks=all
max.in.flight.requests.per.connection=5

Transactions

transactional.id

Makes a set of writes across partitions atomic, and ties the consumer's offset commit into the same transaction. This is what makes consume-transform-produce exactly-once: either the output records and the offset commit both land, or neither does. It survives a restart, because the transactional.id lets a new instance fence the old one.

What to do: The transactional.id must be STABLE per logical producer instance across restarts. Generating a fresh one at startup, which is a very common mistake, gives none of the guarantee and leaks a producer id per restart into the transaction state topic.

transactional.id=payments-processor-${POD_ORDINAL}

producer.initTransactions();
producer.beginTransaction();
producer.send(...);
producer.sendOffsetsToTransaction(offsets, groupMetadata);
producer.commitTransaction();

The consumer half nobody sets

isolation.level=read_committed

The default is read_uncommitted, which means a consumer reads records from transactions that were later aborted. A producer using transactions while its consumer uses the default gets no benefit at all: the whole apparatus is running and the reader still sees aborted data.

What to do: Set isolation.level=read_committed on every consumer downstream of a transactional producer. This is the single most commonly missed setting in an exactly-once pipeline, and the pipeline appears to work without it.

# on EVERY consumer downstream of a transactional producer
isolation.level=read_committed

What it does not cover

Exactly-once is a property of the Kafka-to-Kafka path. The moment a side effect leaves Kafka, an email, an HTTP call, a write to a database without a shared transaction, the guarantee stops at the boundary. Kafka cannot roll back an email.

What to do: For an external side effect, make it idempotent rather than transactional. A de-duplication key derived from the record is more robust than any distributed transaction, and it works with systems that offer none.

The 3.0 default changes, which move the ground under you

A cluster upgrade does not change client defaults, but a client library upgrade does. Two settings changed and both alter the guarantee.

enable.idempotence became true

False before 3.0, true from 3.0. A producer that was plain at-least-once becomes idempotent on a client upgrade with no config change, which is a strict improvement and worth knowing about.

What to do: The trap is the interaction below: setting acks=1 explicitly, which was harmless before, now conflicts with the new default and silently turns idempotence off, or fails startup depending on the version.

acks became all

The default was 1 and is now all. A producer relying on the old default is now slower and safer, which is usually welcome and occasionally a surprise in a latency-sensitive path.

What to do: If you set acks=1 explicitly to get the old behaviour back, you have also disabled idempotence, because idempotence requires acks=all. Set enable.idempotence=false deliberately in that case rather than leaving the combination to be resolved for you.

# these two are the same statement on 3.0+
acks=1
enable.idempotence=false

max.in.flight above 5

Idempotence supports at most 5 in-flight requests per connection. Above that the producer cannot guarantee ordering on a retry, so it refuses to start rather than silently reordering.

What to do: Leave it at 5. Raising it for throughput trades away both ordering and idempotence, which is almost never the trade somebody intends to make.

Ordering, which is a separate promise

Frequently conflated with delivery semantics and governed by different settings.

Order is per partition only

Kafka guarantees order within a partition and offers nothing across partitions. Records for one key are ordered only because the default partitioner sends one key to one partition, so ordering per key depends on the key being set and the partition count not changing.

What to do: Increasing a topic's partition count changes the key-to-partition mapping, so records for one key exist either side of the change and their relative order across the boundary is not preserved. Plan the partition count before ordering matters, not after.

Retries can reorder without idempotence

With idempotence off and more than one request in flight, a failed first request that succeeds on retry lands after a second request that succeeded first. The records are written out of order and nothing reports it.

What to do: Idempotence prevents this, which is another reason to leave the 3.0 default alone. Before 3.0 the equivalent was max.in.flight.requests.per.connection=1, which costs a great deal of throughput.

Common mistakes

These are the ones that fail silently. The config is accepted, nothing raises an error, and the consequence arrives later.

  1. Enabling idempotence and calling it exactly-once

    Idempotence deduplicates producer retries within a session. Exactly-once across a read-process-write cycle needs transactions AND a consumer with isolation.level=read_committed.

    Instead:Configure both halves, or say at-least-once.

  2. Leaving isolation.level at read_uncommitted

    The consumer then reads aborted transactional records as though they were committed, which defeats the producer-side work entirely.

    Instead:Set read_committed on every consumer of a transactional topic.

  3. Assuming exactly-once extends past Kafka

    The guarantee covers Kafka to Kafka. A sink writing to a database is at-least-once unless that write is idempotent too.

    Instead:Make the external write idempotent, usually with a natural key.

Exactly-once has a consumer half, and it is off by default

isolation.level defaults to read_uncommitted, which means a consumer reads records from transactions that were later aborted. A producer running transactions while its consumer uses the default gets none of the guarantee, and the pipeline appears to work.

Three different things share the name

The idempotent producer deduplicates retries within one session, per partition, and does not survive a restart. Transactions make writes across partitions atomic and tie the offset commit into the same transaction, which is what makes consume-transform-produce exactly-once and does survive a restart. Read-committed consumption is what makes either of them visible to the next stage. All three are needed and only the first is on by default.

transactional.id must be stable across restarts

It is the identity that lets a new instance fence the old one, so generating a fresh one at startup gives none of the guarantee and leaks a producer id per restart into the transaction state topic. A StatefulSet ordinal is the usual source. This is the commonest way an exactly-once pipeline is wired up wrong while appearing to run correctly.

transactional.id=payments-processor-${POD_ORDINAL}

The 3.0 defaults moved, and setting acks=1 now costs you idempotence

enable.idempotence became true and acks became all. A client library upgrade therefore changes your guarantee with no config change, which is a strict improvement. The trap is that setting acks=1 explicitly, which was harmless before, now contradicts the idempotence default: you get a producer that is neither what you configured nor what you had.

# these two are the same statement on 3.0+
acks=1
enable.idempotence=false

min.insync.replicas is a topic setting, and acks=all is not enough without it

acks=all waits for the in-sync replicas to acknowledge, and with the default min.insync.replicas of 1 that is the leader alone. A leader failure then loses data that was acknowledged. On a replication factor of 3, setting it to 2 is what makes acks=all mean what people assume it means, and it lives on the topic rather than on the producer.

kafka-configs.sh --bootstrap-server broker:9092 \
  --alter --entity-type topics --entity-name orders \
  --add-config min.insync.replicas=2

The guarantee stops where Kafka stops

Exactly-once covers the Kafka-to-Kafka path. An email, an HTTP call or a write to a database outside the transaction is beyond it, and Kafka cannot roll back an email. For anything leaving Kafka, make the effect idempotent with a de-duplication key derived from the record. That is more robust than a distributed transaction and works with systems that support none.

What this page cannot tell you

Whether your downstream is idempotent, which is the question at-least-once actually turns on. Whether the latency cost of transactions is acceptable for your path. These are the mechanisms and the settings that produce each guarantee; which one you need is a design decision about what a duplicate or a loss would cost you.

More kafka tools

Kafka Confluent Wire Format Decoder The five junk bytes in front of your payload Kafka Key to Partition Mapper Which partition does this key land on? Kafka Topic Name Validator Legal, risky, or 249 characters too long? Kafka Replication Safety Checker How many brokers can you lose Kafka Producer Config Linter Will it start, and will it lose a record? Kafka Message Payload Decoder The first five bytes are usually not data Kafka Connect Source Connector Generator tasks.max is a ceiling, not a count Kafka Connect Sink Connector Generator A dead letter queue with no context headers is a pile of records Kafka Connect SMT Chain Builder The order is the transforms list Kafka MirrorMaker 2 Config Generator It renames every topic by default Kafka Partition Reassignment Generator The throttle is not optional Strimzi Kafka Resource Generator Without the cluster label, nothing happens Kafka mTLS Config Generator The certificate is the identity Kafka Schema Registry Config Generator The compatibility direction is your deployment order Kafka Exactly-Once Config Generator Half of it is worse than none Kafka Broker and KRaft Config Generator The internal topics that break a one-broker cluster Kafka Quota Generator Byte rates are per broker, not per cluster Kafka Streams Config Generator application.id is four things at once Kafka Connect Worker Config Generator Security three times, or the tasks fail Kafka Retention and Unit Converter log.retention.hours does not take milliseconds Kafka Timestamp Converter Two sentinels and two meanings Kafka .properties to YAML Converter Dotted keys stay flat Kafka Streams Internal Topic Predictor Create them before Streams does Kafka ACL Generator The grant you forgot is on another resource type Kafka Topic Config Generator min.insync.replicas is the one that matters Kafka client.properties Generator The file every CLI tool asks for Kafka Producer Config Generator No password field, on purpose Kafka Consumer Config Generator The commit mode decides the semantics Kafka Disk and Retention Calculator retention.bytes is per partition Kafka Partition Count Calculator The number you can never reduce Kafka Cluster Sizing Calculator The traffic no client metric shows Kafka Consumer Lag Catch-Up Calculator Whether it ever clears, not just when Kafka Producer Batching Calculator linger.ms=0 still batches Kafka Segment and Index Sizing Why retention.ms is a lower bound Kafka Rebalance Duration Estimator What a rolling restart really costs Kafka Cost Estimator Your rates, so nothing goes stale Kafka Config Explorer by Version The answer depends on the release Kafka Default Config Reference What moved under a config you never edited Kafka OAuth Bearer Token Decoder Will Kafka accept it, and can it refresh Kafka Record Header Viewer Headers are a list, not a map Kafka Topic Regex Subscription Tester Kafka matches the whole name Kafka ACL Permission Matrix Viewer DENY beats every ALLOW Kafka Connect Config Validator The mistakes that raise no error Kafka Consumer Group Id Validator Which broker coordinates the group Kafka Partition Assignment Visualizer Leadership is the load, not replicas Kafka Consumer Assignment Visualizer The three assignors disagree Kafka ZooKeeper to KRaft Config Converter The authorizer class nobody changes Kafka Config to Strimzi Half of it belongs elsewhere Kafka Docker Compose Generator (KRaft) Reachable from inside and outside Kafka JAAS Config Decoder The line that stops SASL working Kafka CRC32C Calculator Which CRC, over which bytes Kafka Config Upgrade Checker What breaks when you upgrade Kafka Kafka Config Diff Which change actually changed something Kafka Consumer Config Linter Why the group rebalances, and where the records went Kafka Avro Schema Validator The defaults Avro accepts and rejects Kafka Schema Compatibility Checker What the registry will say, before you ask it Kafka Avro Schema Diff Which direction each change breaks Kafka Compression Comparison Measured on your bytes Kafka ksqlDB Query Builder It looks like SQL and the rules are not Kafka Connect SMT Predicate Tester negate reads backwards Kafka Streams Topology Viewer Count the repartitions Kafka Connect Pipeline Visualizer The order things really run in Kafka Protobuf Binary Decoder Works without the .proto Kafka Protobuf JSON Converter Why your JSON does not round-trip Kafka Protobuf to Avro Schema What does not survive the conversion Kafka Avro Binary Decoder Wrong schema, no error Kafka Avro JSON Converter Why the console producer rejects your line Kafka Avro Sample Data Generator Records that actually serialize Kafka JSON to Avro Schema What JSON cannot tell you Kafka JSON Schema to Avro What does not survive the conversion Kafka SASL JAAS Generator One login module, four syntaxes Kafka CLI Command Builder kcat is librdkafka, not Kafka

Elsewhere on the site