Replication & ISR
The idea in one sentence
Section titled “The idea in one sentence”Kafka survives broker failure by keeping each partition on several brokers — one leader and several followers — and it tracks which copies are fully caught up in the in-sync replica (ISR) set, so a failed leader can be replaced without losing acknowledged data.
Leaders and followers
Section titled “Leaders and followers”Each partition has a replication factor: the number of copies Kafka keeps. With replication-factor 3, the partition lives on three brokers. One is the leader — it handles all reads and writes for that partition. The others are followers; they do nothing but continuously fetch from the leader to stay identical.
# 3 copies of every partition, spread across 3 brokerskafka-topics.sh --bootstrap-server localhost:9092 \ --create --topic payments --partitions 6 --replication-factor 3If the leader’s broker dies, one of the followers is promoted to leader and the partition keeps serving. Clients rediscover the new leader through the bootstrap server — no data lost, brief interruption only.
Proof: a real 3-broker cluster, Leader/Replicas/Isr
Section titled “Proof: a real 3-broker cluster, Leader/Replicas/Isr”A 3-broker KRaft cluster (own scratch compose, ports 59092–59094, torn down right after this lesson), topic isr-demo, 3 partitions, replication factor 3:
$ kafka-topics.sh --bootstrap-server localhost:9092 --create --topic isr-demo \ --partitions 3 --replication-factor 3 --config min.insync.replicas=2Created topic isr-demo.
$ kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic isr-demoTopic: isr-demo TopicId: buA7wmUvS8CEP7U5iXWnXA PartitionCount: 3 ReplicationFactor: 3 Configs: min.insync.replicas=2 Topic: isr-demo Partition: 0 Leader: 1 Replicas: 1,2,3 Isr: 1,2,3 Topic: isr-demo Partition: 1 Leader: 2 Replicas: 2,3,1 Isr: 2,3,1 Topic: isr-demo Partition: 2 Leader: 3 Replicas: 3,1,2 Isr: 3,1,2Each partition’s Replicas list is the same three brokers in a different rotation — Kafka spreads leadership round-robin across partitions so no single broker leads everything. Isr starts out equal to Replicas: all three copies are caught up.
Proof: stop a broker, watch ISR shrink and leader failover, live
Section titled “Proof: stop a broker, watch ISR shrink and leader failover, live”Stopping broker 3 (docker stop) and waiting past replica.lag.time.max.ms (default 30000 ms, confirmed below):
$ docker stop kafka-topics-3b-broker3kafka-topics-3b-broker3
# ... wait ~35s for replica.lag.time.max.ms (30000ms default) to elapse ...
$ kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic isr-demoTopic: isr-demo PartitionCount: 3 ReplicationFactor: 3 Configs: min.insync.replicas=2 Topic: isr-demo Partition: 0 Leader: 1 Replicas: 1,2,3 Isr: 1,2 Topic: isr-demo Partition: 1 Leader: 2 Replicas: 2,3,1 Isr: 2,1 Topic: isr-demo Partition: 2 Leader: 1 Replicas: 3,1,2 Isr: 1,2Two things happened, both real: partitions 0 and 1 shrank their ISR from 3 members to 2 (broker 3 dropped, since it stopped fetching); partition 2 — whose leader was broker 3 — failed over, with broker 1 promoted to the new leader and the ISR settling at 1,2. No client intervention, no manual failover command — this is the mechanism the “one broker can die” durability story actually rests on, not a diagram.
flowchart TB
before["Partition 2: Leader=broker3, ISR={3,1,2}"] -->|broker3 stops, replica.lag.time.max.ms elapses| after["Partition 2: Leader=broker1 (promoted), ISR={1,2}"]
p0["Partition 0: ISR={1,2,3}"] -->|broker3 stops, same trigger| p0after["Partition 0: ISR={1,2} (leader unaffected, was broker1)"] Proof: min.insync.replicas rejects writes when ISR drops too low
Section titled “Proof: min.insync.replicas rejects writes when ISR drops too low”With min.insync.replicas=2 and the ISR still at 2 (brokers 1 and 2), an acks=all write still succeeds:
acks=all write SUCCEEDED: [{"topicName":"isr-demo","partition":2,"errorCode":0,"baseOffset":"0", ...}]Stopping a second broker (broker 2) drops every partition’s ISR to just {1} — below min.insync.replicas=2:
$ docker stop kafka-topics-3b-broker2$ kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic isr-demo Topic: isr-demo Partition: 0 Leader: 1 Replicas: 1,2,3 Isr: 1 Elr: 2 Topic: isr-demo Partition: 1 Leader: 1 Replicas: 2,3,1 Isr: 1 Elr: 2 Topic: isr-demo Partition: 2 Leader: 1 Replicas: 3,1,2 Isr: 1 Elr: 2(Elr — Eligible Leader Replicas, a KRaft-era field — lists broker 2 as a replica that could still become leader once it catches back up, even though it just dropped out of the ISR.) An acks=all write against this same topic now fails with a real error, not a timeout or a generic disconnect:
acks=all write FAILED as expected: KafkaJSNumberOfRetriesExceeded | Messages are rejected since there are fewer in-sync replicas than requiredThat message is KafkaJS’s wrapping of the broker’s real NotEnoughReplicasException after retries were exhausted — the durability contract held: rather than accept a write backed by only 1 copy when 2 were promised, the broker refused it outright.
A tempting assumption is that this same rejection is reachable on a single broker too, by setting min.insync.replicas higher than replication-factor can ever satisfy. Testing that assumption directly is exactly what turned up a real surprise:
// @runimport { Kafka } from 'kafkajs';
const brokers = (process.env.KAFKA_BROKERS ?? 'localhost:9092').split(',');const prefix = process.env.TOPIC_PREFIX ?? '';const topic = `${prefix}min-insync-single-broker`;
const kafka = new Kafka({ clientId: 'min-insync-single-broker', brokers });const admin = kafka.admin();await admin.connect();// Single broker -> replication factor can only ever be 1 -> ISR can only ever be size 1.// min.insync.replicas=5 is deliberately absurd: no cluster this small could ever satisfy it.await admin.createTopics({ topics: [{ topic, numPartitions: 1, replicationFactor: 1, configEntries: [{ name: 'min.insync.replicas', value: '5' }] }],}).catch(() => {});await admin.disconnect();
const producer = kafka.producer();await producer.connect();try { const r = await producer.send({ topic, acks: -1, messages: [{ value: 'min.insync.replicas=5 on RF=1 -- does this reject?' }] }); console.log('acks=all write SUCCEEDED:', JSON.stringify(r));} catch (e: any) { console.log('acks=all write FAILED:', e.name, '|', e.message);}await producer.disconnect();process.exit(0);Real output from this exact script, run against a Kafka 4.3.1 broker:
acks=all write SUCCEEDED: [{"topicName":"...-min-insync-single-broker","partition":0,"errorCode":0,"baseOffset":"0", ...}]It succeeds — proven twice in this course’s own testing, once with min.insync.replicas=2 and once with an absurd min.insync.replicas=5, both on a replication-factor=1 topic whose ISR can only ever contain 1 member. This contradicts the intuitive assumption (the one this lesson started with) that min.insync.replicas exceeding what the topology can structurally satisfy would reject every acks=all write forever. Combined with the 3-broker proof above — where min.insync.replicas=2 genuinely did reject writes once the ISR shrank to 1 from a starting size of 3 — the real, provable rule is narrower than “ISR size < min.insync.replicas always rejects”: Kafka’s enforced requirement behaves as if capped by what the partition’s replica set can structurally reach. A min.insync.replicas set above replication-factor does not make writes impossible; it silently behaves as though it were capped at replication-factor. The rejection this lesson proved on the 3-broker cluster only fires when the ISR drops below a level the replica set could otherwise reach — a real, temporary shortfall — not when the configured minimum was simply unreachable from the start.
Proof: restart the broker, ISR expands back
Section titled “Proof: restart the broker, ISR expands back”$ docker start kafka-topics-3b-broker2 kafka-topics-3b-broker3# ... wait ~20s for both to catch up and rejoin ...
$ kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic isr-demo Topic: isr-demo Partition: 0 Leader: 1 Replicas: 1,2,3 Isr: 1,2,3 Topic: isr-demo Partition: 1 Leader: 1 Replicas: 2,3,1 Isr: 1,2,3 Topic: isr-demo Partition: 2 Leader: 1 Replicas: 3,1,2 Isr: 1,2,3ISR is back to all three brokers on every partition. Note partitions 1 and 2’s leader stayed on broker 1 — restarting a broker makes it eligible to rejoin the ISR as a follower again, but it does not automatically reclaim leadership (that would need a deliberate kafka-leader-election.sh --election-type preferred, outside this lesson’s scope).
Durability over availability: unclean leader election
Section titled “Durability over availability: unclean leader election”What if the ISR drops to zero and a stale, out-of-sync replica is the only survivor? Electing it would bring the partition back online but lose the records it never received. Kafka’s default, unclean.leader.election.enable=false, refuses that trade — confirmed against a real running broker, not assumed from documentation:
/opt/kafka/bin/kafka-configs.sh --bootstrap-server $BOOTSTRAP --describe --entity-type brokers --entity-name 1 --all 2>&1 | grep "unclean.leader.election.enable"/opt/kafka/bin/kafka-configs.sh --bootstrap-server $BOOTSTRAP --describe --entity-type brokers --entity-name 1 --all 2>&1 | grep "replica.lag.time.max.ms"Real output (this harness’s own broker):
unclean.leader.election.enable=false sensitive=false synonyms={DEFAULT_CONFIG:unclean.leader.election.enable=false}replica.lag.time.max.ms=30000 sensitive=false synonyms={DEFAULT_CONFIG:replica.lag.time.max.ms=30000}With the default false, Kafka keeps the partition offline until an in-sync replica returns — choosing durability over availability. Flipping it to true prioritizes availability (the partition comes back online immediately, on whatever replica survived) at the explicit cost of silently losing every acknowledged record the elected replica never received. This is a per-topic-overridable, per-cluster-wide-defaultable trade-off, not a hidden setting — anyone flipping it should be doing so deliberately, with the data-loss window understood.
KRaft controller quorum: 3 combined nodes
Section titled “KRaft controller quorum: 3 combined nodes”Since Kafka 4.0 removed ZooKeeper, the controller quorum itself is Kafka brokers (or dedicated controller nodes) using the Raft protocol. On this course’s 3-broker cluster, all three nodes run combined broker+controller roles:
$ kafka-metadata-quorum.sh --bootstrap-server localhost:9092 describe --statusClusterId: 5L6g3nShT-eMCtK--X86swLeaderId: 1LeaderEpoch: 4HighWatermark: 229MaxFollowerLag: 0MaxFollowerLagTimeMs: 0CurrentVoters: [{"id": 1, "endpoints": ["CONTROLLER://broker1:9093"]}, {"id": 2, "endpoints": ["CONTROLLER://broker2:9093"]}, {"id": 3, "endpoints": ["CONTROLLER://broker3:9093"]}]CurrentObservers: []Three voters, one elected LeaderId (the active controller) — this is a separate Raft group from partition leadership above; a broker can be the metadata/controller leader without leading any partition, and vice versa. The same command works against a single-broker cluster too (one voter instead of three) — a useful sanity check that process.roles=broker,controller actually registered this node as a controller:
/opt/kafka/bin/kafka-metadata-quorum.sh --bootstrap-server $BOOTSTRAP describe --statusReal output (this harness’s single-broker probe):
ClusterId: (a real cluster id, single-node)LeaderId: 1CurrentVoters: [{"id": 1, "endpoints": ["CONTROLLER://localhost:9093"]}]CurrentObservers: []One voter, itself — a single-node KRaft “quorum” is really just a quorum of one, which is why single-broker KRaft has no controller-failover story at all. See kraft-and-the-cluster for how this metadata log itself works.
Reading AI-generated code
Section titled “Reading AI-generated code”🐛 Spot the bug
# AI-generated topic config for a payments topic, "matching the durability# guide's recommended settings"# section: topiccleanup.policy=deleteretention.ms=2592000000min.insync.replicas=3Show the answer
This reads like it was assembled from a durability checklist: min.insync.replicas=3 looks like the “safe, high” choice — surely more is safer than the min.insync.replicas=2 this course’s own examples use. The bug is that min.insync.replicas is only meaningful relative to the topic’s replication-factor, which is entirely absent from this file (it is a topic-config file, not a --create command, so the replication factor lives elsewhere — and nothing here checks that the two agree). If this topic was created with --replication-factor 3, min.insync.replicas=3 demands every single replica be in the ISR for any acks=all write to succeed — the ISR tolerates zero broker slowness or downtime before every write starts failing, which is a strictly worse availability trade-off than the standard min.insync.replicas=2 recipe (tolerates one lagging/down broker) for barely more durability. Worse, if this topic was created with the common default --replication-factor 3 but someone later reads this config in isolation and assumes “3 is extra-safe,” they may not realize it makes the topic less available than the review’s own recommended RF=3, min.insync.replicas=2, acks=all. The fix is not a syntax fix — the file is valid — it is checking min.insync.replicas against the actual replication-factor the topic was created with and asking whether the availability cost was actually intended.
Mental Model
Section titled “Mental Model”The ISR is not a static list decided once at topic-creation time — it is a live, continuously re-evaluated membership, proven above shrinking within seconds of a broker going quiet and expanding again once it catches back up. Think of acks=all + min.insync.replicas as a standing quorum requirement on whichever brokers happen to be in the ISR right now, not on the topic’s nominal replication factor — proven above: the exact same replication-factor=3 topic accepted acks=all writes with 2 brokers up and rejected them with 1, without any config change in between. The number that matters at write time is the live ISR size, not the number in --describe’s Replicas column.
Trade-offs
Section titled “Trade-offs”unclean.leader.election.enable=false(default, confirmed above) — never loses acknowledged data to a stale leader, but a partition whose entire ISR is down stays offline until an in-sync replica returns, however long that takes.unclean.leader.election.enable=true— the partition comes back online immediately on whatever replica survives, at the explicit cost of silently losing any acknowledged record the elected replica never received — availability over durability, the opposite default.min.insync.replicas=2onreplication-factor=3(this course’s baseline recipe) — tolerates one broker being down or lagging with zero write rejections, proven above; the SpotTheBug’smin.insync.replicas=3on the same RF tolerates zero, for a durability gain (every replica confirmed) that most workloads don’t need at that availability cost.- A single-broker dev cluster with
min.insync.replicasset above 1 (proven above to have no effect) — cheap to set up, but proven not to exercise theNotEnoughReplicasExceptionrejection path at all; testing that specific failure mode needs at least 2 brokers with a real one stopped, proven above on the 3-broker cluster.
Common Misconceptions
Section titled “Common Misconceptions”- “The ISR is fixed once a topic is created, based on its replication factor.” Proven false above: the same
replication-factor=3topic’s ISR shrank to 2, then to 1, then expanded back to 3, entirely from brokers stopping and restarting — nothing about the topic’s own config changed. - “If a write with
acks=allsucceeds, it’s backed byreplication-factorcopies.” Proven false above: it is backed by however many replicas are in the ISR at that moment, which can be less thanreplication-factor— areplication-factor=3topic with an ISR of 2 still acceptsacks=allwrites, backed by only 2 copies. - “A higher
min.insync.replicasis always a safer choice.” Proven above (SpotTheBug) to be a real availability trade-off, not a free upgrade:min.insync.replicasequal toreplication-factormeans any single broker being down or slow rejects every write. - “Setting
min.insync.replicasabovereplication-factormakes writes stricter, or at least fails safely.” Proven false above, twice, on a single RF=1 broker:min.insync.replicas=2and evenmin.insync.replicas=5both still letacks=allwrites through. The enforced requirement is capped by what the replica set can structurally reach — it is not a hard floor independent of topology. - “Restarting a failed broker immediately makes it the leader again for partitions it used to lead.” Proven false above: broker 1, promoted to leader for partition 2 while broker 3 was down, stayed the leader after broker 3 rejoined the ISR — rejoining the ISR and reclaiming leadership are separate events.
- “The KRaft controller quorum and partition leadership are the same mechanism.” Proven above to be two separate things:
kafka-metadata-quorum.sh describe --statusshows a completely separate Raft group (metadata/controller leadership) fromkafka-topics.sh --describe’s per-partitionLeadercolumn (data-plane leadership) — a broker’s role in one says nothing about its role in the other.