MongoDB · lesson 8 of 10
Intermediate
6- 2 Embedding vs Referencing: A 5.9MB Document and a 50x Fetch Time
- 3 explain() Proves It: 200,000 Docs Scanned vs. a Covered Query That Touches Zero
- 4 The Aggregation Stage-Order Bug: Wrong Results, No Error
- 5 When the Aggregation Optimizer Can (and Can't) Use Your Index
- 6 Change Streams: Watching a Collection in Real Time (Replica Set Required)
- 7 Multi-Document Transactions: When One Update Isn't Enough
Advanced
3Also part of MongoDB
1Also part of: System Design Databases
Killing the Primary and Measuring the Real Failover Time
docker kill the primary node of a real 3-node replica set and time exactly how long the cluster takes to elect a new one from container logs and MongoDB's own timestamps — 9.01 seconds, closely matching the documented default election timeout.
By SkillByExample · Published
Not an assertion that failover works — a real 3-node cluster, a real docker kill, and a real
stopwatch.
Confirming the starting state
docker exec mongo-rs1 mongosh --quiet --eval 'rs.status().members.map(m => ({name: m.name, state: m.stateStr}))'
[
{ name: 'mongo-rs1:27017', state: 'PRIMARY' },
{ name: 'mongo-rs2:27017', state: 'SECONDARY' },
{ name: 'mongo-rs3:27017', state: 'SECONDARY' }
]
mongo-rs1 is the current primary, from the replica set built in the change-streams lesson.
Killing the primary, precisely
docker inspect mongo-rs2 --format "{{.State.FinishedAt}}"
2026-09-13T16:01:25.992145189Z
docker kill mongo-rs2 (run just before this) sends SIGKILL immediately — no graceful
shutdown window to blur the timing, unlike docker stop. Docker’s own recorded stop timestamp
is the real starting point for the measurement.
docker exec mongo-rs1 mongosh --quiet --eval '
const status = rs.status();
const primary = status.members.find(m => m.stateStr === "PRIMARY");
printjson({ name: primary.name, electionDate: primary.electionDate });
'
{ name: 'mongo-rs1:27017', electionDate: ISODate('2026-09-13T16:01:35.000Z') }
16:01:35.000 minus 16:01:25.992 is 9.01 seconds — real, measured, from MongoDB’s own
recorded election timestamp against Docker’s own recorded kill timestamp. That’s close to
MongoDB’s documented default electionTimeoutMillis of 10 seconds: how long a replica set
waits without contact from the primary before starting an election. This isn’t a coincidence
to assert — it’s a number that lines up with documentation because both are measuring the same
real timeout.
The bug: writes fail during the gap
docker exec mongo-rs3 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
try {
db.accounts.insertOne({ name: "eve", balance: 5 });
} catch (e) {
print("ERROR: " + e.message);
}
'
ERROR: not primary
Run against a secondary during the roughly 9-second gap where no primary exists yet, a write
fails outright. This is the real cost of automatic failover: not zero downtime, but a bounded,
short one — bounded by electionTimeoutMillis, not by how long it takes a human to notice and
intervene.
After the election: the old primary rejoins as a secondary
docker exec mongo-rs3 mongosh --quiet --eval 'rs.status().members.map(m => ({name: m.name, state: m.stateStr}))'
[
{ name: 'mongo-rs1:27017', state: 'SECONDARY' },
{ name: 'mongo-rs2:27017', state: 'PRIMARY' },
{ name: 'mongo-rs3:27017', state: 'SECONDARY' }
]
mongo-rs2 won the election and stays primary — restarting mongo-rs1 doesn’t hand primary
status back. Nothing about the original primary is special once someone else has taken over.
Practice
1. Confirm a write survives correctly once a new primary is in place.
docker exec mongo-rs2 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
printjson(db.accounts.insertOne({ name: "eve", balance: 5 }));
'
{ acknowledged: true, insertedId: ObjectId('...') }
Writing directly to the new primary (mongo-rs2, confirmed primary in the previous step)
succeeds normally — the “not primary” error was specific to the roughly 9-second gap, not a
lasting problem with the cluster.
2. Check which node the current primary considers to be lagging, if any.
docker exec mongo-rs2 mongosh --quiet --eval '
rs.status().members.map(m => ({ name: m.name, state: m.stateStr, lastHeartbeat: m.lastHeartbeat }));
'
[
{ name: 'mongo-rs1:27017', state: 'SECONDARY', lastHeartbeat: ISODate('2026-09-13T16:04:09.272Z') },
{ name: 'mongo-rs2:27017', state: 'PRIMARY', lastHeartbeat: undefined },
{ name: 'mongo-rs3:27017', state: 'SECONDARY', lastHeartbeat: ISODate('2026-09-13T16:04:09.855Z') }
]
Note which entry has lastHeartbeat: undefined — it isn’t the node that just recovered from
being killed (mongo-rs1), it’s mongo-rs2, the node this query was actually run against. A
member never heartbeats itself; it only tracks heartbeats received from the others. This is
the field that would reveal a lagging or unreachable secondary in a cluster with a real
ongoing problem, but reading it requires knowing which entry is “self” before drawing any
conclusion from a missing value.
3. Read rs.conf() to see the actual configured election timeout, rather than assuming the documented default applies.
docker exec mongo-rs2 mongosh --quiet --eval 'rs.conf().settings.electionTimeoutMillis'
10000
10000 milliseconds confirms this cluster is running the documented default of 10 seconds —
the measured 9.01-second failover lines up with it directly, not with an assumption about what
the setting “probably” is.
Next: sharding — partitioning data across multiple shards by key.