Consensus

The Raft Log: Commit Index, Divergence and Reconciliation

The leader writing an entry to its own log means nothing. An entry is committed when it is replicated to a majority — and until then it is a proposal that a future leader is free to delete. Understanding that one distinction explains every log divergence you will ever debug.

▶ Run the lab

The question this answers

The question

When is an entry actually committed, and what happens to entries a deposed leader wrote but never committed?

The guarantee — the property claimed, and its scope

Log Matching: if two logs contain an entry with the same index and term, the logs are identical in every entry up to that index. State Machine Safety: if a node has applied an entry at index i, no other node ever applies a different entry at index i. An entry is committed — durable and never revocable — exactly when it is stored on a majority *and* was appended in the current leader’s term. Entries below that bar may be discarded without notice.

Everything below is bought to hold this sentence. "Strongly consistent" with no scope attached is a slogan, not a guarantee — read what it actually covers, and what it explicitly does not.

What a node knows — observation versus inference

A follower knows the entries it holds and the commit index the leader last told it about; it does not know whether its most recent entries are committed, and it must not apply them until told. The leader knows which entries each follower has acknowledged — as of the last reply it received, which may be stale. A client whose request timed out knows nothing about whether its entry was committed, and there is no local check either party can perform to find out. Commitment is a property of the cluster, learned by counting, never observed.

A node knows its own state and the messages that arrived. Everything else is inference from evidence that was already stale. "B has not replied in five seconds" is knowledge; "B is down" is a decision — and usually the bug.

What guarantee?What does a node know?How does it work?What can fail?How does it fail?Where is coordination?What holds under failure?How does it recover?How would you know?What is the simpler thing?
raftreplicated logcommit indexdivergencetruncation

Local append is not a commit

The leader receives a client command, appends it to its own log at the next index, stamped with the current term, and sends AppendEntries to every follower. At this moment the entry exists on exactly one machine and is worth nothing. If the leader crashes now, a successor may — legitimately, correctly — erase it.

The entry becomes committed when the leader has received acknowledgements from a majority, itself included. Only then may the leader advance its commitIndex, apply the entry to its state machine, and answer the client. This ordering is the whole of the durability contract: acknowledge on commit, never on append. An implementation that returns success at append time will lose acknowledged writes on failover, and it will do so silently.

The consequence for clients is uncomfortable and unavoidable. A client that times out cannot know whether its entry committed; it may commit seconds later. This is A Timeout Tells You Nothing About Whether It Happened at the storage layer, and the answer is the same: make the operation identifiable and retryable rather than trying to learn the outcome.

Index 6 is on a majority and committed; index 7 is on the leader only and is notprotocol
123456indexN1 (leader, term 4)★ leaderN1 (leader, term 4) index 1: term 1, set x=1 — committedt1set x=1N1 (leader, term 4) index 2: term 1, set y=2 — committedt1set y=2N1 (leader, term 4) index 3: term 2, set x=5 — committedt2set x=5N1 (leader, term 4) index 4: term 3, del y — committedt3del yN1 (leader, term 4) index 5: term 4, set z=9 — committedt4set z=9N1 (leader, term 4) index 6: term 4, set x=7 — replicated but not committedt4set x=7N1 (leader, term 4): commit index 5▲ commit 5N2 (follower)followerN2 (follower) index 1: term 1, set x=1 — committedt1set x=1N2 (follower) index 2: term 1, set y=2 — committedt1set y=2N2 (follower) index 3: term 2, set x=5 — committedt2set x=5N2 (follower) index 4: term 3, del y — committedt3del yN2 (follower) index 5: term 4, set z=9 — committedt4set z=9N2 (follower): commit index 5▲ commit 5N3 (follower, lagging)followerN3 (follower, lagging) index 1: term 1, set x=1 — committedt1set x=1N3 (follower, lagging) index 2: term 1, set y=2 — committedt1set y=2N3 (follower, lagging) index 3: term 2, set x=5 — committedt2set x=5N3 (follower, lagging): commit index 3▲ commit 3
committed (solid)replicated, not committed (dashed)
Entry 5 (`set z=9`) is on N1 and N2 — a majority of three — so it is committed and applied. Entry 6 (`set x=7`) exists only on the leader; if N1 dies now, N2 can win the election and entry 6 disappears with no client ever having been told it succeeded.

The commit index, and the rule that surprises people

commitIndex is the highest index known to be committed. The leader computes it by looking at what a majority has acknowledged, then piggybacks it on the next AppendEntries so followers learn how far they may apply. Followers never compute it themselves — commitment is decided by the leader and propagated.

The rule that catches everyone: a leader may not commit an entry from a previous term merely because it is now on a majority. Raft only advances commitIndex past an entry once an entry *from the leader’s own current term* has been replicated to a majority. Counting replicas of an old-term entry is not sufficient, because a subsequent leader could still overwrite it in a way that would contradict a commit already announced.

In practice a new leader appends a no-op entry in its own term immediately after election. Once that no-op commits, everything before it commits with it. If you have ever wondered why a freshly elected Raft leader writes an empty entry, this is why — and it is not an optimisation, it is required for safety.

leader state (5 nodes, currentTerm = 4):
  nextIndex[]  = { N2: 7, N3: 4, N4: 7, N5: 4 }   # what to send next
  matchIndex[] = { N2: 6, N3: 3, N4: 6, N5: 3 }   # highest known replicated

  replicated-on-majority = 3rd largest of {6 (self), 6, 3, 6, 3} = 6

  commit rule: advance commitIndex to 6
               ONLY IF log[6].term == currentTerm (== 4)
               otherwise wait for a current-term entry to replicate
What the leader tracks per follower, and how commitIndex is derived

Divergence: how logs come apart

Picture a leader in term 4 that accepts three client writes at indexes 7, 8 and 9, replicates none of them, and then crashes — or is partitioned, which is worse because it keeps accepting writes. Those three entries exist on one node and nowhere else.

Meanwhile N2 and N3 elect N2 in term 5. N2 is legitimate: by Leader Completeness it holds every *committed* entry, and entries 7–9 were never committed, so their absence is not a problem. N2 accepts its own client writes at indexes 7, 8 and 9, stamped with term 5.

Now two logs disagree at the same indexes with different terms. Both nodes behaved correctly. No client was misled, because nobody was ever told entries 7–9 in term 4 had succeeded. Divergence in uncommitted tails is a normal, expected state, not a bug — and the protocol must resolve it without human involvement.

After N1 rejoins: two logs, disagreeing from index 7, both produced correctlyprotocol
56789indexN1 (old leader, term 4)followerN1 (old leader, term 4) index 5: term 3, set a=1 — committedt3set a=1N1 (old leader, term 4) index 6: term 4, set b=2 — committedt4set b=2N1 (old leader, term 4) index 7: term 4, ORPHAN set c=3 — replicated but not committed — diverges from N2 (new leader, term 5) (term 5, no-op (new leader))t4ORPHAN set c=3N1 (old leader, term 4) index 8: term 4, ORPHAN set c=4 — replicated but not committed — diverges from N2 (new leader, term 5) (term 5, set d=8)t4ORPHAN set c=4N1 (old leader, term 4) index 9: term 4, ORPHAN set c=5 — replicated but not committed — diverges from N2 (new leader, term 5) (term 5, set e=9)t4ORPHAN set c=5N1 (old leader, term 4): commit index 6▲ commit 6N2 (new leader, term 5)★ leaderN2 (new leader, term 5) index 5: term 3, set a=1 — committedt3set a=1N2 (new leader, term 5) index 6: term 4, set b=2 — committedt4set b=2N2 (new leader, term 5) index 7: term 5, no-op (new leader) — committedt5no-op (new leader)N2 (new leader, term 5) index 8: term 5, set d=8 — committedt5set d=8N2 (new leader, term 5) index 9: term 5, set e=9 — committedt5set e=9N2 (new leader, term 5): commit index 9▲ commit 9
committed (solid)replicated, not committed (dashed)diverges from N2 (new leader, term 5) — will be overwritten
Indexes 5 and 6 match, so by Log Matching everything up to 6 is identical. From index 7 they disagree. N1’s three term-4 entries were never committed and were never acknowledged to any client; they will be truncated.

Reconciliation: the consistency check that repairs everything

Every AppendEntries carries prevLogIndex and prevLogTerm — the entry immediately before the ones being sent. A follower accepts only if it has an entry at prevLogIndex whose term matches. If it does not, it rejects, and the leader decrements nextIndex for that follower and tries again with an earlier point.

This walks backwards until leader and follower agree on a common prefix. From there the leader sends everything after it, and the follower truncates its divergent tail and adopts the leader’s entries. The old leader’s three term-4 entries are deleted, unread and unmourned.

Two properties make this safe rather than reckless. First, Log Matching: agreement on one (index, term) implies agreement on the entire prefix, so a single matching point proves the whole history matches — that is why a backwards search is sufficient. Second, Leader Completeness: the leader doing the overwriting already holds every committed entry, so truncation can only ever destroy entries that were never committed and never acknowledged. The leader’s log is the log, and that is a safe thing to say only because of how leaders are chosen.

1# leader -> follower
2AppendEntries(term, prevLogIndex, prevLogTerm, entries[], leaderCommit)
3
4# follower
5if term < currentTerm: return Reject(currentTerm)
6if log has no entry at prevLogIndex: return Reject(hint=len(log))
7if log[prevLogIndex].term != prevLogTerm: return Reject(hint=first index of that term)
8
9# prefix agrees from here down (Log Matching)
10for (i, e) in entries:
11 if log has entry at prevLogIndex+1+i with a different term:
12 truncate(from = prevLogIndex+1+i) # discard divergent tail
13 append(e)
14
15commitIndex = min(leaderCommit, index of last new entry)
16apply committed entries in index order
17return Ok
18
19# leader, on Reject:
20nextIndex[follower] -= 1 # or jump using the hint
21retry # converges on the common prefix
The consistency check — reconciliation is a side effect of one comparison

What this means for a client

The honest client-side contract is narrower than people assume. An acknowledged write is durable and will survive any number of leader changes. An unacknowledged write is in superposition: it may commit, it may be truncated, and there is no bound on when the question resolves — a partitioned leader’s entry can be truncated minutes later.

So a client that times out must not assume failure. It must retry with an identifier that lets the state machine recognise a duplicate, exactly as Idempotent Is a Property of the Whole Effect, Not the Write describes. And a read that must reflect all committed writes cannot simply be served by any follower: followers lag, and a follower’s commitIndex trails the leader’s. Linearizable reads require going through the leader with a confirmed quorum, or a read-index protocol — see Linearizability: An Operation Is an Interval, Not a Point.

Key points

  • An entry on the leader’s disk is a proposal; an entry on a majority in the current term is a commit.
  • Acknowledge clients on commit, never on local append.
  • A leader may not commit a previous term’s entry by replica count alone — hence the no-op entry after election.
  • Divergent uncommitted tails are normal after a leader change, not a defect.
  • The prevLogIndex/prevLogTerm check walks back to a common prefix; the follower then truncates and adopts the leader’s log.
  • Truncation is safe because Leader Completeness means the leader already holds everything committed.

The chain, answered

Every field here is required, which is why no lesson in this domain can recommend a design without naming what an operator sees when it fails, what survives the partition, what repairs it afterwards, and the simpler thing to consider first.

How it works
  • Client sends a command; the leader appends it locally with the current term at the next index.
  • The leader sends AppendEntries with prevLogIndex/prevLogTerm to every follower.
  • Followers verify the previous entry matches, truncate any conflicting tail, append, and acknowledge.
  • The leader tracks matchIndex per follower and computes the highest index replicated on a majority.
  • If that index holds a current-term entry, commitIndex advances; the leader applies and answers the client.
  • The new commitIndex rides along on the next AppendEntries, letting followers apply in the same order.
  • On rejection, the leader decrements nextIndex (or uses the follower’s hint) and retries earlier until the prefixes match.
What can fail at the boundary
  • AppendEntries is lost, so a follower falls behind and its matchIndex stalls.
  • The leader crashes after appending locally but before replicating, orphaning entries.
  • The leader crashes after replicating to a majority but before answering the client — committed, but the client saw a timeout.
  • A follower crashes and restarts having lost entries it acknowledged, breaking the durability assumption.
  • A slow follower diverges far enough that the leader has already compacted the entries it needs, requiring a snapshot transfer instead.
  • Applying is not deterministic across nodes, so identical logs produce different state — a state machine bug that the protocol cannot detect.
How it fails — what an operator sees
  • Acknowledged writes lost on failover: an implementation acked on append. The operator sees clients reporting successful writes that are absent afterwards, and a truncation line in the old leader’s log at exactly those indexes.
  • One follower stuck far behind: the operator sees a flat matchIndex for one member, growing disk on the leader because compaction is blocked, and a cluster that will lose quorum if one more node fails despite all nodes being "up".
  • Commit index frozen with a live leader: the leader cannot replicate a current-term entry to a majority. The operator sees the leader healthy, commitIndex static, client writes timing out, and — the tell — the last log entry’s term lower than currentTerm.
  • Snapshot storm: a follower is so far behind that the leader must ship a full snapshot; the transfer saturates the link and slows replication to everyone. The operator sees replication latency rising cluster-wide during a single member’s recovery.
  • Divergent applied state with identical logs: a non-deterministic state machine (map iteration order, a wall-clock read, a random seed). The operator sees identical commitIndex on every member and different query answers — the worst of the set, because the protocol reports perfect health.
Where coordination is required
  • One majority round trip per commit, pipelined and batched so throughput is not one-commit-per-round-trip.
  • The commit latency is the latency of the *median* member of the fastest majority, so one slow follower is tolerated and two are not.
  • Reads are only cheap if you accept staleness; a linearizable read needs its own quorum confirmation and costs a round trip too.
What still holds under failure
  • Every committed entry survives any sequence of leader changes, by Leader Completeness.
  • Uncommitted entries may vanish at any later time, without notice and without an error to the client.
  • Log Matching holds at all times: any two logs agreeing at an (index, term) agree on the entire prefix.
  • With no majority, commitIndex freezes; nothing is lost and nothing progresses.
How it recovers
  • Detect: alert on commitIndex not advancing while a leader exists, and on per-follower replication lag in entries, not seconds.
  • Contain: refuse to acknowledge before commit; fail fast when the leader cannot reach a majority rather than queueing.
  • Recover: the consistency check reconciles automatically; badly lagging followers are caught up by snapshot install.
  • Reconcile: divergent uncommitted tails are truncated by the protocol. Effects already emitted outside the state machine are not — they need Fencing Tokens: Making the Stale Actor Safe, Not Just Unlikely or idempotence.
  • Verify: confirm every member reports the same commitIndex and lastApplied, and compare a state-machine checksum across members to catch non-determinism.
How you would know
  • commitIndex and lastApplied per member on one graph.
  • Per-follower matchIndex gap from the leader, in entries — the real replication health signal.
  • Commit latency at p99, which tracks the median majority member.
  • Snapshot install events and their duration.
  • A periodic state-machine hash comparison across members — the only way to catch non-deterministic apply.
When it helps
  • When you need an ordered, durable sequence of operations that survives node loss with no manual failover.
  • When the state machine on top is deterministic, so replicating the log replicates the state exactly.
  • When you want a single explanation for both replication and recovery — in Raft they are the same code path.
When it hurts
  • When operations are independent and do not need a global order: you are paying for total ordering you never use.
  • When entries are large, since every entry crosses the network to every member and lands on every disk.
  • When one member is chronically slow, because it blocks compaction and erodes the failure margin without ever appearing down.
Simpler alternatives

Raft: five nodes, one log

Raft: five nodes, one log
Every button below is a Raft event handed to the engine's raftStep. Kill the leader, cut a node off, hold its messages in flight, append entries — the safety rules are the protocol's, not the widget's.
target node
highest term
1
leaders (believing)
n1
commit index
0
largest reachable group
5 of 5 · majority 3
Who can talk to whom, and what each node currently thinks it is.protocol
n1 ↔ n2: okn1 ↔ n3: okn1 ↔ n4: okn1 ↔ n5: okn2 ↔ n3: okn2 ↔ n4: okn2 ↔ n5: okn3 ↔ n4: okn3 ↔ n5: okn4 ↔ n5: okn1 · leader · term 1 · upn1★ leaderterm 1n2 · follower · term 1 · upn2· followerterm 1n3 · follower · term 1 · upn3· followerterm 1n4 · follower · term 1 · upn4· followerterm 1n5 · follower · term 1 · upn5· followerterm 1
ok
protocol
indexn1★ leaderempty logn1: commit index 0▲ commit 0n2followerempty logn2: commit index 0▲ commit 0n3followerempty logn3: commit index 0▲ commit 0n4followerempty logn4: commit index 0▲ commit 0n5followerempty logn5: commit index 0▲ commit 0
committed (solid)replicated, not committed (dashed)
n1 leads term 1. Solid entries are committed — stored on a majority *and* written in the current term. Dashed entries are proposals: a future leader may delete them, and no client will be told.
event log — step 3 of 3
stepping back and then acting rewrites history from here
1·n1n1 timed out and stands for election in term 1, voting for itself (1 of 3 needed).
2·n2n2 votes for n1 in term 1 (2 of 3 needed).
2·n3n3 votes for n1 in term 1 (3 of 3 needed).
2·n4n4 votes for n1 in term 1 (4 of 3 needed).
2·n5n5 votes for n1 in term 1 (5 of 3 needed).
2·n1n1 wins term 1 with 5 of 5 votes. It knows its log is at least as current as every voter's — that is what the vote actually certified.
3·n2n2 accepts the append and now stores 0 entries at term 1.
3·n3n3 accepts the append and now stores 0 entries at term 1.
3·n4n4 accepts the append and now stores 0 entries at term 1.
3·n5n5 accepts the append and now stores 0 entries at term 1.
protocolOne leader per term, commit only on majority replication, terms never regress, and an entry from an older term is refused commit by replica count alone (§5.4.2). All enforced by the engine and covered by its tests.
simplifiedEvents are stepped by hand instead of by a clock. There are no election timers, no batching, no pipelining, no snapshots, and a follower adopts the leader’s log wholesale rather than walking nextIndex backwards.
assumption"Delayed" holds a message on the wire indefinitely until you deliver it. Real delay is bounded eventually — and that eventual bound is the only thing Raft’s liveness ever rests on.

Log divergence and truncation

What happens to entries a deposed leader never committed
n1 leads, commits one entry, then is cut off. It keeps accepting client writes it can never replicate. Meanwhile n2 is elected by the majority and moves on. Slide the number of orphaned writes and watch the reconciliation.
accepted by n1
3
ever committed
0
clients acknowledged
0
survive the heal
0
1 — after the split, before the heal
protocol
1234indexn1★ leadern1 index 1: term 1, set a=1 — committedt1set a=1n1 index 2: term 1, orphan 1 — replicated but not committedt1orphan 1n1 index 3: term 1, orphan 2 — replicated but not committedt1orphan 2n1 index 4: term 1, orphan 3 — replicated but not committedt1orphan 3n1: commit index 1▲ commit 1n2★ leadern2 index 1: term 1, set a=1 — committedt1set a=1n2 index 2: term 2, set b=2 — committed — diverges from n1 (term 1, orphan 1)t2set b=2n2: commit index 2▲ commit 2n3followern3 index 1: term 1, set a=1 — committedt1set a=1n3 index 2: term 2, set b=2 — committed — diverges from n1 (term 1, orphan 1)t2set b=2n3: commit index 2▲ commit 2n4followern4 index 1: term 1, set a=1 — committedt1set a=1n4 index 2: term 2, set b=2 — committed — diverges from n1 (term 1, orphan 1)t2set b=2n4: commit index 2▲ commit 2n5followern5 index 1: term 1, set a=1 — committedt1set a=1n5 index 2: term 2, set b=2 — committed — diverges from n1 (term 1, orphan 1)t2set b=2n5: commit index 2▲ commit 2
committed (solid)replicated, not committed (dashed)diverges from n1 — will be overwritten
n1 holds 3 entries nobody else has, marked ✕ against the leader's log. Its commit index never moved, because it never reached a majority. n2 was elected by the four nodes that could still talk, and committed its own entry in a higher term.
2 — after the heal
protocol
12indexn1followern1 index 1: term 1, set a=1 — committedt1set a=1n1 index 2: term 2, set b=2 — committedt2set b=2n1: commit index 2▲ commit 2n2★ leadern2 index 1: term 1, set a=1 — committedt1set a=1n2 index 2: term 2, set b=2 — committedt2set b=2n2: commit index 2▲ commit 2n3followern3 index 1: term 1, set a=1 — committedt1set a=1n3 index 2: term 2, set b=2 — committedt2set b=2n3: commit index 2▲ commit 2n4followern4 index 1: term 1, set a=1 — committedt1set a=1n4 index 2: term 2, set b=2 — committedt2set b=2n4: commit index 2▲ commit 2n5followern5 index 1: term 1, set a=1 — committedt1set a=1n5 index 2: term 2, set b=2 — committedt2set b=2n5: commit index 2▲ commit 2
committed (solid)replicated, not committed (dashed)
One AppendEntries from n2 carrying the higher term is enough. n1 steps down, adopts term 2, and its divergent tail is gone. No error is raised and no client is notified, because no client was ever told those writes succeeded.
3 writes vanished, and every client that submitted one saw a timeout — which told it nothing. A timeout is compatible with "never happened", "happened and was lost", and "will commit in four seconds". The failure to guard against here is an implementation that acknowledges on local append instead of on commit: it converts this from an honest timeout into silent data loss.
protocolLog Matching and State Machine Safety: two logs agreeing at an (index, term) agree on the whole prefix, and no two nodes ever apply different entries at the same index. Truncation only ever removes uncommitted, unacknowledged entries.
protocolCommit requires majority replication *and* an entry from the current term. Counting replicas of an older entry is the figure-8 bug, and the engine refuses it explicitly.
simplifiedThe follower here adopts the leader’s log wholesale. Real Raft walks nextIndex backwards to the last agreeing entry, or ships a snapshot when the entries no longer exist.

What people believe, and what is true

Claim

Once the leader writes the entry, it is committed.

Reality

It is a proposal on one machine. A future leader may delete it, and no client will ever be told.

Claim

The client got a timeout, so the entry was not committed.

Reality

It may commit after the timeout. The client learns nothing from a timeout — see A Timeout Tells You Nothing About Whether It Happened.

Claim

Truncating a follower’s log means losing data.

Reality

Only uncommitted, unacknowledged entries are ever truncated. Committed entries cannot be, because the leader that overwrites necessarily holds them.

Claim

A follower can serve consistent reads because it has the log.

Reality

Its commitIndex lags the leader’s, so it may hold entries it must not apply and miss entries it has not received.

Claim

Replicating an old entry to a majority commits it.

Reality

Raft explicitly forbids that. A current-term entry must reach a majority first — hence the no-op after every election.

Go deeper

Only the levels this lesson can honestly fill — a missing level is a claim nobody had.

Overview

The leader appends client commands to a log and copies them to followers. An entry counts as committed once a majority has it; only then is it applied and the client answered. Entries that never reached a majority can be deleted by the next leader.

Practical

Acknowledge on commit, not on append. Watch per-follower matchIndex gaps rather than node up/down. Expect log truncation after a leader change and treat it as normal. Keep the state machine deterministic — no clocks, no map iteration order, no randomness — and checksum applied state across members periodically.

Advanced

The current-term commit restriction is the subtlest rule in Raft. Without it, an entry replicated to a majority by an old leader could be reported committed and still be overwritten by a later leader that never saw it, breaking State Machine Safety. The no-op entry a new leader appends is the practical device that makes previous-term entries committable, and its absence is a classic bug in hand-rolled implementations.

Internals

Compaction turns the unbounded log into a snapshot plus a suffix. A follower behind the leader’s snapshot point cannot be repaired by the backwards nextIndex walk — the entries no longer exist — so the leader ships an InstallSnapshot instead, which is expensive and must be rate-limited to avoid starving normal replication. Compaction is also why a chronically lagging follower is an availability risk rather than a cosmetic one: the leader must retain log entries until the slowest member catches up, so one stuck follower grows the leader’s disk until it must choose between unbounded growth and snapshot-shipping.

Apply it

Build it, then break it
  • 🔧 Construct the data-loss scenario that occurs if a leader commits a previous-term entry purely on replica count.
  • 🔧 Explain why the backwards nextIndex walk is guaranteed to terminate at a correct common prefix.
  • 🔧 List three sources of non-determinism in a state machine and how you would detect each in production.
Reason about this
  • A leader accepts 500 writes while partitioned and acknowledges none of them. It rejoins 90 seconds later. Describe precisely what happens to those 500 entries and what each client saw.
Interview questions
  • 💬 When exactly is a Raft entry committed?
  • 💬 Two nodes have different entries at index 7. Which one is wrong, and how does the cluster resolve it?
  • 💬 Why does a new Raft leader append a no-op entry?
  • 💬 A client times out on a write. What are the possible fates of that entry?