The demo below shows a five-node Raft cluster. Each node is either a Leader (gold), Follower (blue), or Candidate (amber). Click Start Election to kick off a leader election, then Append Entry to add a log entry that replicates to all followers. Try Crash Follower to drop a node and watch the cluster keep working.
<p class="hint">A five-node Raft cluster. Each node is a <b>Leader</b> (gold), <b>Follower</b> (blue), or <b>Candidate</b> (amber). Click the buttons to run an election, append a log entry, or crash a follower.</p>
<div id="cluster"></div>
<div id="log-panel">
<div class="log-title">Replicated Log</div>
<div id="log-entries"></div>
</div>
<div class="status" id="status">Click "Start Election" to begin.</div>
<div class="btns">
<button id="btn-elect" type="button">Start Election</button>
<button id="btn-append" type="button">Append Entry</button>
<button id="btn-crash" type="button" class="ghost">Crash Follower</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .8rem; line-height: 1.45; }
#cluster { display: flex; gap: 10px; margin: .5rem 0 .8rem; flex-wrap: wrap; }
.node { width: 80px; padding: 8px 4px; border-radius: 10px; text-align: center;
border: 2px solid transparent; transition: all .25s; user-select: none; }
.node-name { font: 700 13px ui-monospace, monospace; margin-bottom: 3px; }
.node-role { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
.node-term { font-size: 11px; color: #666; margin-top: 2px; }
.node.leader { background: #ffe680; border-color: #c8a000; }
.node.leader .node-role { color: #7a5f00; }
.node.follower { background: #dbeafe; border-color: #3b82f6; }
.node.follower .node-role { color: #1e40af; }
.node.candidate { background: #fde68a; border-color: #d97706; }
.node.candidate .node-role { color: #92400e; }
.node.crashed { background: #f1f5f9; border-color: #94a3b8; opacity: .55; }
.node.crashed .node-role { color: #64748b; }
#log-panel { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;
padding: 8px 10px; margin-bottom: .7rem; min-height: 44px; }
.log-title { font: 700 12px system-ui; color: #64748b; text-transform: uppercase;
letter-spacing: .06em; margin-bottom: 6px; }
#log-entries { display: flex; gap: 6px; flex-wrap: wrap; }
.log-entry { padding: 4px 8px; border-radius: 6px; font: 600 12px ui-monospace, monospace;
border: 1.5px solid #3b82f6; background: #eff6ff; color: #1d4ed8; }
.log-entry.committed { border-color: #16a34a; background: #f0fdf4; color: #166534; }
.status { font-size: .95rem; font-weight: 600; margin: .4rem 0; min-height: 1.4em; }
.status.ok { color: #166534; }
.status.warn { color: #92400e; }
.status.info { color: #1e40af; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .3rem; }
button { font: 600 13px system-ui; padding: .42rem .85rem; border-radius: 8px; cursor: pointer;
border: 1.5px solid #1d3557; background: #1d3557; color: #fff; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Raft consensus demo: leader election + log replication across 5 nodes.
// Simplified: network is instantaneous; terms increment on each election.
// A majority = 3 of 5 nodes needed to commit entries or elect a leader.
const TOTAL = 5;
const MAJORITY = 3;
let state = null;
function initState() {
return {
nodes: Array.from({ length: TOTAL }, (_, i) => ({
id: i,
name: 'N' + (i + 1),
role: 'follower', // 'leader' | 'follower' | 'candidate' | 'crashed'
term: 0,
votedFor: null,
log: [] // array of { index, term, value, committed }
})),
term: 0,
leaderId: null,
nextEntry: 1 // next log value to propose
};
}
function aliveNodes(s) {
return s.nodes.filter(n => n.role !== 'crashed');
}
// โโ Render โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function render(s) {
const cluster = document.getElementById('cluster');
cluster.innerHTML = '';
s.nodes.forEach(n => {
const div = document.createElement('div');
div.className = 'node ' + n.role;
div.innerHTML =
fmt('<div class="node-name">%name%</div><div class="node-role">%role%</div><div class="node-term">term %term%</div>', { name: n.name, role: n.role, term: n.term });
cluster.appendChild(div);
});
// Show leader's log (or first alive node's if no leader)
const src = s.leaderId !== null ? s.nodes[s.leaderId]
: (aliveNodes(s)[0] || s.nodes[0]);
const logEl = document.getElementById('log-entries');
logEl.innerHTML = '';
src.log.forEach(e => {
const d = document.createElement('div');
d.className = 'log-entry' + (e.committed ? ' committed' : '');
d.textContent = '[' + e.index + '] op:' + e.value;
logEl.appendChild(d);
});
}
function setStatus(text, cls) {
const el = document.getElementById('status');
el.textContent = text;
el.className = 'status ' + (cls || '');
}
// โโ Election โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function runElection(s) {
const alive = aliveNodes(s);
if (alive.length < MAJORITY) {
setStatus(fmt('Not enough nodes for an election (need %MAJORITY%).', { MAJORITY: MAJORITY }), 'warn');
return;
}
// Pick a random candidate from followers
const followers = alive.filter(n => n.role === 'follower');
if (followers.length === 0 && s.leaderId !== null) {
setStatus(fmt('Already have a leader: %name%.', { name: s.nodes[s.leaderId].name }), 'info');
return;
}
const candidate = followers.length > 0
? followers[Math.floor(Math.random() * followers.length)]
: alive[Math.floor(Math.random() * alive.length)];
s.term += 1;
candidate.role = 'candidate';
candidate.term = s.term;
candidate.votedFor = candidate.id;
// Gather votes from alive nodes
let votes = 1; // candidate votes for itself
alive.forEach(n => {
if (n.id === candidate.id) return;
// In real Raft, a node only votes if candidate's log is at least as up-to-date.
// Here we simplify: if not voted yet in this term, vote yes.
if (n.term < s.term) {
n.term = s.term;
n.votedFor = candidate.id;
votes++;
}
});
if (votes >= MAJORITY) {
// Won election
s.leaderId = candidate.id;
s.nodes.forEach(n => {
if (n.role === 'crashed') return;
if (n.id === candidate.id) {
n.role = 'leader';
// Copy leader's log to followers (simplified: all followers sync)
} else {
n.role = 'follower';
n.log = [...candidate.log]; // followers mirror leader log
}
});
setStatus(fmt('%name% won election with %votes%/%TOTAL% votes (term %term%).', { name: candidate.name, votes: votes, TOTAL: TOTAL, term: s.term }), 'ok');
} else {
candidate.role = 'follower';
setStatus(fmt('Election failed โ %name% got %votes% votes, needed %MAJORITY%.', { name: candidate.name, votes: votes, MAJORITY: MAJORITY }), 'warn');
}
render(s);
}
// โโ Append Entry โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function appendEntry(s) {
if (s.leaderId === null || s.nodes[s.leaderId].role !== 'leader') {
setStatus('No leader yet โ run an election first.', 'warn');
return;
}
const leader = s.nodes[s.leaderId];
const entry = { index: s.nextEntry, term: s.term, value: s.nextEntry, committed: false };
s.nextEntry++;
// Append to leader
leader.log.push(entry);
// Replicate to alive followers
const alive = aliveNodes(s).filter(n => n.id !== leader.id);
alive.forEach(n => n.log.push({ ...entry }));
// Count acknowledgements (leader + alive followers)
const acks = 1 + alive.length; // all alive nodes replicated immediately
if (acks >= MAJORITY) {
// Commit on all nodes
s.nodes.forEach(n => {
const e = n.log.find(x => x.index === entry.index);
if (e) e.committed = true;
});
setStatus(fmt('Entry %index% replicated to %v%%length% nodes and committed.', { index: entry.index, v: 1, length: alive.length }), 'ok');
} else {
setStatus(fmt('Entry %index% appended but not yet committed (%acks%/%MAJORITY% acks).', { index: entry.index, acks: acks, MAJORITY: MAJORITY }), 'warn');
}
render(s);
}
// โโ Crash Follower โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function crashFollower(s) {
const followers = s.nodes.filter(n => n.role === 'follower');
if (followers.length === 0) {
setStatus('No followers to crash.', 'warn');
return;
}
const victim = followers[Math.floor(Math.random() * followers.length)];
victim.role = 'crashed';
const alive = aliveNodes(s).length;
const msg = alive >= MAJORITY
? fmt('%name% crashed. %alive% nodes alive โ cluster still has majority.', { name: victim.name, alive: alive })
: fmt('%name% crashed. Only %alive% nodes alive โ below majority, writes blocked!', { name: victim.name, alive: alive });
setStatus(msg, alive >= MAJORITY ? 'ok' : 'warn');
render(s);
}
// โโ Init โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
state = initState();
render(state);
document.getElementById('btn-elect').addEventListener('click', () => runElection(state));
document.getElementById('btn-append').addEventListener('click', () => appendEntry(state));
document.getElementById('btn-crash').addEventListener('click', () => crashFollower(state));
document.getElementById('btn-reset').addEventListener('click', () => {
state = initState();
render(state);
setStatus('Click "Start Election" to begin.', '');
});
<p class="hint">A five-node Raft cluster. Each node is a <b>Leader</b> (gold), <b>Follower</b> (blue), or <b>Candidate</b> (amber). Click the buttons to run an election, append a log entry, or crash a follower.</p>
<div id="cluster"></div>
<div id="log-panel">
<div class="log-title">Replicated Log</div>
<div id="log-entries"></div>
</div>
<div class="status" id="status">Click "Start Election" to begin.</div>
<div class="btns">
<button id="btn-elect" type="button">Start Election</button>
<button id="btn-append" type="button">Append Entry</button>
<button id="btn-crash" type="button" class="ghost">Crash Follower</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .8rem; line-height: 1.45; }
#cluster { display: flex; gap: 10px; margin: .5rem 0 .8rem; flex-wrap: wrap; }
.node { width: 80px; padding: 8px 4px; border-radius: 10px; text-align: center;
border: 2px solid transparent; transition: all .25s; user-select: none; }
.node-name { font: 700 13px ui-monospace, monospace; margin-bottom: 3px; }
.node-role { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
.node-term { font-size: 11px; color: #666; margin-top: 2px; }
.node.leader { background: #ffe680; border-color: #c8a000; }
.node.leader .node-role { color: #7a5f00; }
.node.follower { background: #dbeafe; border-color: #3b82f6; }
.node.follower .node-role { color: #1e40af; }
.node.candidate { background: #fde68a; border-color: #d97706; }
.node.candidate .node-role { color: #92400e; }
.node.crashed { background: #f1f5f9; border-color: #94a3b8; opacity: .55; }
.node.crashed .node-role { color: #64748b; }
#log-panel { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;
padding: 8px 10px; margin-bottom: .7rem; min-height: 44px; }
.log-title { font: 700 12px system-ui; color: #64748b; text-transform: uppercase;
letter-spacing: .06em; margin-bottom: 6px; }
#log-entries { display: flex; gap: 6px; flex-wrap: wrap; }
.log-entry { padding: 4px 8px; border-radius: 6px; font: 600 12px ui-monospace, monospace;
border: 1.5px solid #3b82f6; background: #eff6ff; color: #1d4ed8; }
.log-entry.committed { border-color: #16a34a; background: #f0fdf4; color: #166534; }
.status { font-size: .95rem; font-weight: 600; margin: .4rem 0; min-height: 1.4em; }
.status.ok { color: #166534; }
.status.warn { color: #92400e; }
.status.info { color: #1e40af; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .3rem; }
button { font: 600 13px system-ui; padding: .42rem .85rem; border-radius: 8px; cursor: pointer;
border: 1.5px solid #1d3557; background: #1d3557; color: #fff; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Raft consensus demo: leader election + log replication across 5 nodes.
// Simplified: network is instantaneous; terms increment on each election.
// A majority = 3 of 5 nodes needed to commit entries or elect a leader.
const TOTAL = 5;
const MAJORITY = 3;
let state = null;
function initState() {
return {
nodes: Array.from({ length: TOTAL }, (_, i) => ({
id: i,
name: 'N' + (i + 1),
role: 'follower', // 'leader' | 'follower' | 'candidate' | 'crashed'
term: 0,
votedFor: null,
log: [] // array of { index, term, value, committed }
})),
term: 0,
leaderId: null,
nextEntry: 1 // next log value to propose
};
}
function aliveNodes(s) {
return s.nodes.filter(n => n.role !== 'crashed');
}
// โโ Render โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function render(s) {
const cluster = document.getElementById('cluster');
cluster.innerHTML = '';
s.nodes.forEach(n => {
const div = document.createElement('div');
div.className = 'node ' + n.role;
div.innerHTML =
fmt('<div class="node-name">%name%</div><div class="node-role">%role%</div><div class="node-term">term %term%</div>', { name: n.name, role: n.role, term: n.term });
cluster.appendChild(div);
});
// Show leader's log (or first alive node's if no leader)
const src = s.leaderId !== null ? s.nodes[s.leaderId]
: (aliveNodes(s)[0] || s.nodes[0]);
const logEl = document.getElementById('log-entries');
logEl.innerHTML = '';
src.log.forEach(e => {
const d = document.createElement('div');
d.className = 'log-entry' + (e.committed ? ' committed' : '');
d.textContent = '[' + e.index + '] op:' + e.value;
logEl.appendChild(d);
});
}
function setStatus(text, cls) {
const el = document.getElementById('status');
el.textContent = text;
el.className = 'status ' + (cls || '');
}
// โโ Election โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function runElection(s) {
const alive = aliveNodes(s);
if (alive.length < MAJORITY) {
setStatus(fmt('Not enough nodes for an election (need %MAJORITY%).', { MAJORITY: MAJORITY }), 'warn');
return;
}
// Pick a random candidate from followers
const followers = alive.filter(n => n.role === 'follower');
if (followers.length === 0 && s.leaderId !== null) {
setStatus(fmt('Already have a leader: %name%.', { name: s.nodes[s.leaderId].name }), 'info');
return;
}
const candidate = followers.length > 0
? followers[Math.floor(Math.random() * followers.length)]
: alive[Math.floor(Math.random() * alive.length)];
s.term += 1;
candidate.role = 'candidate';
candidate.term = s.term;
candidate.votedFor = candidate.id;
// Gather votes from alive nodes
let votes = 1; // candidate votes for itself
alive.forEach(n => {
if (n.id === candidate.id) return;
// In real Raft, a node only votes if candidate's log is at least as up-to-date.
// Here we simplify: if not voted yet in this term, vote yes.
if (n.term < s.term) {
n.term = s.term;
n.votedFor = candidate.id;
votes++;
}
});
if (votes >= MAJORITY) {
// Won election
s.leaderId = candidate.id;
s.nodes.forEach(n => {
if (n.role === 'crashed') return;
if (n.id === candidate.id) {
n.role = 'leader';
// Copy leader's log to followers (simplified: all followers sync)
} else {
n.role = 'follower';
n.log = [...candidate.log]; // followers mirror leader log
}
});
setStatus(fmt('%name% won election with %votes%/%TOTAL% votes (term %term%).', { name: candidate.name, votes: votes, TOTAL: TOTAL, term: s.term }), 'ok');
} else {
candidate.role = 'follower';
setStatus(fmt('Election failed โ %name% got %votes% votes, needed %MAJORITY%.', { name: candidate.name, votes: votes, MAJORITY: MAJORITY }), 'warn');
}
render(s);
}
// โโ Append Entry โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function appendEntry(s) {
if (s.leaderId === null || s.nodes[s.leaderId].role !== 'leader') {
setStatus('No leader yet โ run an election first.', 'warn');
return;
}
const leader = s.nodes[s.leaderId];
const entry = { index: s.nextEntry, term: s.term, value: s.nextEntry, committed: false };
s.nextEntry++;
// Append to leader
leader.log.push(entry);
// Replicate to alive followers
const alive = aliveNodes(s).filter(n => n.id !== leader.id);
alive.forEach(n => n.log.push({ ...entry }));
// Count acknowledgements (leader + alive followers)
const acks = 1 + alive.length; // all alive nodes replicated immediately
if (acks >= MAJORITY) {
// Commit on all nodes
s.nodes.forEach(n => {
const e = n.log.find(x => x.index === entry.index);
if (e) e.committed = true;
});
setStatus(fmt('Entry %index% replicated to %v%%length% nodes and committed.', { index: entry.index, v: 1, length: alive.length }), 'ok');
} else {
setStatus(fmt('Entry %index% appended but not yet committed (%acks%/%MAJORITY% acks).', { index: entry.index, acks: acks, MAJORITY: MAJORITY }), 'warn');
}
render(s);
}
// โโ Crash Follower โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function crashFollower(s) {
const followers = s.nodes.filter(n => n.role === 'follower');
if (followers.length === 0) {
setStatus('No followers to crash.', 'warn');
return;
}
const victim = followers[Math.floor(Math.random() * followers.length)];
victim.role = 'crashed';
const alive = aliveNodes(s).length;
const msg = alive >= MAJORITY
? fmt('%name% crashed. %alive% nodes alive โ cluster still has majority.', { name: victim.name, alive: alive })
: fmt('%name% crashed. Only %alive% nodes alive โ below majority, writes blocked!', { name: victim.name, alive: alive });
setStatus(msg, alive >= MAJORITY ? 'ok' : 'warn');
render(s);
}
// โโ Init โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
state = initState();
render(state);
document.getElementById('btn-elect').addEventListener('click', () => runElection(state));
document.getElementById('btn-append').addEventListener('click', () => appendEntry(state));
document.getElementById('btn-crash').addEventListener('click', () => crashFollower(state));
document.getElementById('btn-reset').addEventListener('click', () => {
state = initState();
render(state);
setStatus('Click "Start Election" to begin.', '');
});
Comments
Loading comments...