Skip to main content
System Design · lesson 8 of 11
Open the System Design page →
System Design advanced Lesson 8 of 11

Consensus: A Real Raft-Style Leader Election, Split Vote, and Failover

Simulate a 5-node cluster with real randomized election timeouts — a clean election, a genuine split vote, and a re-election after the leader is killed.

By SkillByExample · Published

system-design consensus raft distributed-systems python

Distributed systems need one node to act as leader — coordinating writes, say — but any node can crash at any moment. Consensus is how a cluster agrees on who that is, survives that node dying, and never ends up with two nodes both convinced they’re the leader.

A clean election, then a real leader crash

import random

FOLLOWER, CANDIDATE, LEADER = "follower", "candidate", "leader"

class Node:
    def __init__(self, node_id):
        self.id = node_id
        self.state = FOLLOWER
        self.term = 0
        self.voted_for = {}  # term -> node_id voted for
        self.reset_timeout()

    def reset_timeout(self):
        self.timer = random.randint(150, 300)  # ms — real randomized range, like Raft's spec

def run_election(nodes, candidate):
    candidate.term += 1
    candidate.voted_for[candidate.term] = candidate.id
    votes = 1  # votes for self
    print(f"  node-{candidate.id} times out, starts election for term {candidate.term} (timeout was {candidate.timer}ms)")
    for n in nodes:
        if n.id == candidate.id:
            continue
        already_voted = candidate.term in n.voted_for
        if candidate.term > n.term and not already_voted:
            n.term = candidate.term
            n.voted_for[candidate.term] = candidate.id
            n.reset_timeout()  # hearing a candidate resets the follower's own timer
            votes += 1
            print(f"    node-{n.id} grants vote to node-{candidate.id} for term {candidate.term}")
        else:
            print(f"    node-{n.id} denies vote (term={n.term}, already_voted={already_voted})")
    majority = len(nodes) // 2 + 1
    print(f"  term {candidate.term}: node-{candidate.id} got {votes}/{len(nodes)} votes (majority={majority})")
    if votes >= majority:
        candidate.state = LEADER
        print(f"  >>> node-{candidate.id} becomes LEADER for term {candidate.term}\n")
        return candidate
    return None

random.seed()  # unseeded — a real, genuinely different draw every run
nodes = [Node(i) for i in range(5)]

print("=== Initial election ===")
leader = min(nodes, key=lambda n: n.timer)
leader = run_election(nodes, leader)
print(f"Leader: node-{leader.id}, term {leader.term}\n")

print("=== Leader crashes — followers must detect and re-elect ===")
nodes.remove(leader)
print(f"node-{leader.id} (the leader) is killed. {len(nodes)} nodes remain.\n")
new_leader = min(nodes, key=lambda n: n.timer)
new_leader = run_election(nodes, new_leader)
print(f"New leader: node-{new_leader.id}, term {new_leader.term}")
$ python raft_election.py
=== Initial election ===
  node-3 times out, starts election for term 1 (timeout was 183ms)
    node-0 grants vote to node-3 for term 1
    node-1 grants vote to node-3 for term 1
    node-2 grants vote to node-3 for term 1
    node-4 grants vote to node-3 for term 1
  term 1: node-3 got 5/5 votes (majority=3)
  >>> node-3 becomes LEADER for term 1

Leader: node-3, term 1

=== Leader crashes — followers must detect and re-elect ===
node-3 (the leader) is killed. 4 nodes remain.

  node-2 times out, starts election for term 2 (timeout was 164ms)
    node-0 grants vote to node-2 for term 2
    node-1 grants vote to node-2 for term 2
    node-4 grants vote to node-2 for term 2
  term 2: node-2 got 4/4 votes (majority=3)
  >>> node-2 becomes LEADER for term 2

New leader: node-2, term 2

Timeouts are drawn from a real, unseeded random range, so re-running this prints different node IDs and millisecond values every time — that’s the point, not a bug to fix. In this run, node-3’s randomized 183ms timeout fires first, so it campaigns and every other node — none of which have voted yet this term — grants it a vote, giving it all 5/5. Killing it doesn’t require any special handling: the remaining 4 nodes simply keep counting down their own timers, and whichever one hits zero first (node-2, at 164ms) starts a new term and wins the same way. The term number going from 1 to 2 is the whole mechanism for detecting “there’s a new leader now” — no node needs to be told the old one died.

A real split vote, and why the retry resolves it

Randomized timeouts make an exact tie unlikely, but they don’t stop two or three candidates from campaigning in the same narrow window before any of them hears the others’ votes:

import random

def concurrent_election(node_ids, candidate_ids, term):
    tallies = {c: 1 for c in candidate_ids}  # self-votes
    voters = [n for n in node_ids if n not in candidate_ids]
    for voter in voters:
        order = candidate_ids[:]
        random.shuffle(order)  # which RequestVote arrives first is genuinely random
        winner = order[0]
        tallies[winner] += 1
        print(f"    node-{voter} sees node-{winner}'s RequestVote first, votes for it")
    majority = len(node_ids) // 2 + 1
    print(f"  term {term} tally: " + ", ".join(f"node-{c}={v}" for c, v in tallies.items()) + f" (majority={majority})")
    winners = [c for c, v in tallies.items() if v >= majority]
    return winners[0] if winners else None

random.seed()
node_ids = [0, 1, 2, 3, 4]
candidates = [0, 1, 2]  # three nodes time out within the same network-delay window

print("=== node-0, node-1, and node-2 all time out within the same window — three-way campaign for term 1 ===")
result = concurrent_election(node_ids, candidates, term=1)
print("  no candidate reached a majority — genuine split vote\n" if result is None else f"  node-{result} won outright\n")

print("=== Retry: every node backs off with a fresh random election timeout ===")
timeouts = {n: random.randint(150, 300) for n in node_ids}
for n, t in sorted(timeouts.items(), key=lambda kv: kv[1]):
    print(f"  node-{n} new timeout: {t}ms")
retry_candidate = min(timeouts, key=timeouts.get)
print(f"  node-{retry_candidate} times out first this round, campaigns alone for term 2")
votes = 1
for n in node_ids:
    if n != retry_candidate:
        votes += 1
        print(f"    node-{n} grants vote to node-{retry_candidate} for term 2")
majority = len(node_ids) // 2 + 1
print(f"  term 2: node-{retry_candidate} got {votes}/{len(node_ids)} votes (majority={majority})")
print(f"  >>> node-{retry_candidate} becomes LEADER for term 2")
$ python raft_split_vote.py
=== node-0, node-1, and node-2 all time out within the same window — three-way campaign for term 1 ===
    node-3 sees node-0's RequestVote first, votes for it
    node-4 sees node-1's RequestVote first, votes for it
  term 1 tally: node-0=2, node-1=2, node-2=1 (majority=3)
  no candidate reached a majority — genuine split vote

=== Retry: every node backs off with a fresh random election timeout ===
  node-1 new timeout: 156ms
  node-3 new timeout: 174ms
  node-0 new timeout: 185ms
  node-2 new timeout: 217ms
  node-4 new timeout: 272ms
  node-1 times out first this round, campaigns alone for term 2
    node-0 grants vote to node-1 for term 2
    node-2 grants vote to node-1 for term 2
    node-3 grants vote to node-1 for term 2
    node-4 grants vote to node-1 for term 2
  term 2: node-1 got 5/5 votes (majority=3)
  >>> node-1 becomes LEADER for term 2

With 3 candidates and only 2 remaining voters, the electorate genuinely can split — node-3 and node-4 each pick a different front-runner, leaving node-0 and node-1 tied at 2 votes each, short of the 3-vote majority. Nobody becomes leader for term 1. The fix isn’t clever: every node just draws a fresh random timeout and tries again. Because the new timeouts are independently random, it’s very unlikely all three would collide a second time in a row — here node-1’s new 156ms draw wins clean, and term 2 resolves unanimously.

Practice

1. What's the majority threshold for a 7-node cluster? For a 2-node cluster?
for n in [7, 2]:
    print(f"{n} nodes: majority = {n // 2 + 1}")
7 nodes: majority = 4
2 nodes: majority = 2

A 2-node cluster needs unanimous agreement — its majority equals its total size. This is why production Raft clusters use odd sizes (3, 5, 7): an odd cluster can lose a node and still have a clear majority among the rest, while an even cluster loses that margin for free.

2. A node has already voted for someone else this term. Does a second candidate's request get denied even if that candidate's term number is technically valid?
class Node:
    def __init__(self, node_id, term=0):
        self.id = node_id
        self.term = term
        self.voted_for = {}

nodes = [Node(0, term=2), Node(1, term=1), Node(2, term=1)]
candidate = nodes[1]
candidate.term += 1
candidate.voted_for[2] = candidate.id
votes = 1
for n in nodes:
    if n.id == candidate.id:
        continue
    already_voted = candidate.term in n.voted_for
    if candidate.term > n.term and not already_voted:
        n.term = candidate.term
        n.voted_for[candidate.term] = candidate.id
        votes += 1
        print(f"node-{n.id} grants vote to node-{candidate.id} for term {candidate.term}")
    else:
        print(f"node-{n.id} denies vote to node-{candidate.id} for term {candidate.term} (term={n.term}, already_voted={already_voted})")
print(f"node-{candidate.id} got {votes}/3 votes")
node-0 denies vote to node-1 for term 2 (term=2, already_voted=False)
node-2 grants vote to node-1 for term 2
node-1 got 2/3 votes

node-0 denies the vote purely because it’s already at term 2 (candidate.term > n.term is false, not because it already voted) — a node never votes for a candidate whose term isn’t strictly ahead of its own. node-2, still on term 1, grants it. One vote per node per term is Raft’s core safety rule: it’s what makes “majority voted for me” mean something.

3. If the cluster splits into a 3-node majority partition and a 2-node minority partition, what happens in each?

The majority partition (3 of 5 nodes) can still reach the majority threshold of 3 entirely among itself, so it can elect a leader and keep operating. The minority partition (2 of 5) can never reach 3 votes no matter how many times it retries — any node there that campaigns will keep losing, forever, until the partition heals. It can’t do anything incorrect (no rogue leader), but it also makes zero progress — Raft picks correctness over availability for the minority side.

4. Why does killing the leader node in this simulation not require the followers to be told it died?

Because followers never depend on being told anything — they only track how long it’s been since they last heard from a leader (via heartbeats, not simulated in this simplified election- only model) or the last time they reset their own timer. A dead leader is indistinguishable from a live one that’s just being slow, right up until a follower’s timeout expires and it starts a new election on its own initiative. This is what makes the failover fully decentralized: no single node’s failure detector has to be “right” for the system to recover.

Frequently Asked Questions

Is this a full Raft implementation?
No — it implements Raft's core election rule (a candidate wins only with votes from a majority of the cluster, one vote per node per term, terms only increase) but skips log replication, which is the other half of real Raft. The election mechanics — randomized timeouts, terms, majority quorums, split votes — are real and match the paper; this lesson isolates just that part.
Why does a split vote happen at all if timeouts are randomized to avoid ties?
Randomized timeouts make an exact tie unlikely, but they don't prevent multiple nodes from timing out within the same network round-trip window — if two or three candidates' RequestVote messages are all in flight before any of them hears back, the electorate splits between whichever candidate's message a given follower happens to process first. Raft's fix isn't preventing this; it's making the retry cheap and likely to resolve, which is what this lesson's second example shows.
Why does granting a vote reset the voter's own election timer?
It's a sign of a functioning leader candidate nearby, so there's no reason for this node to also start campaigning. If it didn't reset the timer, every node's timeout would fire on roughly the same schedule they started with, defeating the point of randomization.
What actually happens if the cluster splits into two network partitions?
The partition containing a majority of nodes can still elect a leader and keep functioning. The minority partition can never reach majority (by definition), so any node there that tries to campaign will keep losing elections and retrying forever — it can't do anything harmful, but it also can't make progress, which is Raft trading availability in the minority partition for correctness.
How do real systems use this? Isn't Raft mostly a paper exercise?
No — etcd, Consul, CockroachDB, and TiKV all run real Raft implementations in production for exactly this: agreeing on who's allowed to be the leader/coordinator when nodes can crash or lose network connectivity. This lesson's election mechanics are the same rules those systems run, just without the log-replication half.