~ayush.dev
5 min read

How Raft actually replicates a log

Leader election is the part everyone remembers. The interesting rules live in log matching, commit indexes, and the two places Raft refuses to trust a majority.

  • distributed-systems
  • consensus
  • raft

Most explanations of Raft stop at "a leader is elected, then it replicates entries." That covers the happy path and almost none of the reasoning. The parts that actually keep the log consistent are three invariants that hold whether or not anything is on fire.

The state every node keeps

A node's durable state is small. Everything else can be rebuilt from it after a crash.

node-state.ts
type PersistentState = {
  currentTerm: number; // highest term seen, monotonic
  votedFor: NodeId | null; // vote cast in currentTerm
  log: LogEntry[]; // 1-indexed, never truncated at the head
};
 
type VolatileState = {
  commitIndex: number; // highest entry known committed
  lastApplied: number; // highest entry handed to the state machine
};
 
type LogEntry = {
  term: number; // term the leader held when it created this entry
  command: Command;
};

The term on each entry is what makes the whole protocol work. It is not bookkeeping — it is the only thing that lets a node compare two logs and decide which one is more current without talking to anyone else.

The Log Matching Property

Raft maintains a property that sounds almost too strong:

If two logs contain an entry with the same index and term, then the logs are identical in all entries up through that index.

You get it from one rule during replication. Every AppendEntries carries the index and term of the entry immediately preceding the new ones, and the follower refuses the request unless it has a matching entry there.

append-entries.ts
function handleAppendEntries(req: AppendEntriesRequest): AppendEntriesResponse {
  if (req.term < state.currentTerm) {
    return { term: state.currentTerm, success: false };
  }
 
  // The consistency check. This single comparison is what propagates
  // the Log Matching Property backwards through the entire log.
  const prev = state.log[req.prevLogIndex];
  if (req.prevLogIndex > 0 && prev?.term !== req.prevLogTerm) {
    return { term: state.currentTerm, success: false };
  }
 
  appendAndOverwriteConflicts(req.entries, req.prevLogIndex);
 
  if (req.leaderCommit > state.commitIndex) {
    state.commitIndex = Math.min(req.leaderCommit, lastIndex(state.log));
  }
 
  return { term: state.currentTerm, success: true };
}

It is an induction. The check holds for the first entry trivially, and each successful append extends the guarantee by one. A follower whose log diverged simply keeps rejecting until the leader walks nextIndex back far enough to find the last point of agreement.

Why the leader never deletes its own entries

Followers overwrite conflicting suffixes. Leaders never do. This asymmetry is deliberate: a leader that could rewrite its own log would be able to un-commit an entry that a client was already told had succeeded.

Commitment is not majority-acknowledged

This is the rule most implementations get wrong on the first attempt. An entry is not committed simply because a majority of nodes have it on disk.

A leader may only mark an entry committed if:

  1. The entry is stored on a majority of servers, and
  2. The entry was created in the leader's current term.

The second condition looks redundant. It isn't. Without it there's a real interleaving where a majority-replicated entry gets overwritten.

StepLeaderLog stateOutcome
1S1 (t2)replicates entry@2 to S1,S2on a majority? not yet
2S5 (t3)elected by S3,S4,S5S5's log lacks entry@2
3S1 (t4)replicates entry@2 to S3now on a majority — tempting
4S5 (t5)elected again, overwrites @2that "majority" entry is gone

If S1 had been allowed to commit at step 3 on majority alone, step 4 would erase committed state. Requiring the entry to be from the current term closes the hole: entries from prior terms only become committed indirectly, when a current-term entry above them commits.

Committing a no-op on election

Because of that rule, a freshly elected leader can be stuck unable to commit anything inherited from previous terms. The standard fix is to append an empty entry immediately on election.

on-become-leader.ts
function onBecomeLeader() {
  for (const peer of peers) {
    nextIndex.set(peer, lastIndex(state.log) + 1);
    matchIndex.set(peer, 0);
  }
 
  // A no-op in the current term. Committing it drags every
  // inherited entry below it over the commit line with it.
  appendLocal({ term: state.currentTerm, command: { kind: "noop" } });
}

Elections only pick sufficiently current logs

The other place Raft refuses to trust a bare majority is voting. A node grants its vote only if the candidate's log is at least as up to date as its own, compared by (lastLogTerm, lastLogIndex) lexicographically.

request-vote.ts
function handleRequestVote(req: RequestVoteRequest): RequestVoteResponse {
  if (req.term < state.currentTerm) {
    return { term: state.currentTerm, voteGranted: false };
  }
 
  const alreadyVoted =
    state.votedFor !== null && state.votedFor !== req.candidateId;
  if (alreadyVoted) return { term: state.currentTerm, voteGranted: false };
 
  // Later term always wins; same term falls through to longer log.
  const ours = { term: lastLogTerm(state.log), index: lastIndex(state.log) };
  const upToDate =
    req.lastLogTerm > ours.term ||
    (req.lastLogTerm === ours.term && req.lastLogIndex >= ours.index);
 
  if (!upToDate) return { term: state.currentTerm, voteGranted: false };
 
  state.votedFor = req.candidateId;
  return { term: state.currentTerm, voteGranted: true };
}

Combined with the commitment rule, this gives the Leader Completeness Property: any entry committed in some term is present in the logs of all leaders of higher terms. A committed entry is on a majority; any winning candidate needs votes from a majority; two majorities intersect; the intersecting node would have refused the vote if the candidate were missing that entry.

What this buys you in practice

  • Crash recovery is free. A restarted node replays its durable log and lets the consistency check repair any divergent tail.
  • Reads still need care. A leader that has been partitioned may not know it. Serving a read from local state without a heartbeat round is a stale read, not a linearizable one.
  • Log compaction is separate. Snapshotting is not part of the consensus core; it's a mechanism for discarding a committed prefix that every member already has.

The elegance of Raft isn't the leader election — it's that these three rules compose into safety without any node needing a global view. Every decision is local, and the intersection of majorities does the rest.