How MongoDB's Default Write Concern Rolled Back 266 Confirmed Orders During a Replica Set Election
← Back
October 31, 2026Database8 min read

How MongoDB's Default Write Concern Rolled Back 266 Confirmed Orders During a Replica Set Election

Published October 31, 20268 min read

02:14 UTC. Zendesk lights up with eleven tickets in nine minutes, same shape: "I got a confirmation email, Stripe charged my card, but your order history says I don't have an order." The on-call engineer's first move is the Stripe dashboard. Every charge is real, captured, settled. The orders just aren't in the database.


the scramble

The obvious suspect is the maintenance window AWS had scheduled six hours earlier for the underlying host of one node in the orders replica set, three members across two AZs. That ticket is already closed, marked no-impact: request success rate held at 100% through the window, error rate dashboards flat, the replica set elected a new primary and kept serving traffic without a single failed write. Textbook failover. Nobody reopens a ticket that says everything worked.

Second theory: a client-side double-submit bug sending confirmation emails for orders that never actually got created. Ruled out fast, the confirmation email template only fires server-side, after the insert into orders returns success. If the email went out, the write was acknowledged. Something acknowledged isn't supposed to just stop existing afterward.

Third theory, the one that goes somewhere: someone plots the affected order timestamps against the maintenance window and they land in a seven-second band right at the start of it, 02:14:03 to 02:14:10 UTC. Not scattered across the whole maintenance period. One tight window, at the exact moment the failover happened.


the hunt

The checkout service's insert path looks ordinary. It was tuned six weeks earlier, ahead of a product launch, specifically to shave write latency:

checkout-api/createOrder.ts, as it shipped
const result = await db.collection('orders').insertOne(orderDoc, {
  writeConcern: { w: 1 }, // changed from default 'majority' for latency, 6 weeks ago
});

await kafka.send({
  topic: 'order.confirmed',
  messages: [{ value: JSON.stringify(orderDoc) }],
});

await sendConfirmationEmail(orderDoc.customerEmail, orderDoc._id);
return { orderId: orderDoc._id, status: 'confirmed' };

w: 1 means the primary acknowledges the write the moment it's durable on its own disk, before any secondary has it. That's the latency win: no waiting on cross-AZ replication round trips. It's also the whole story, once you know to look for it. The insert returning success only means the node that was primary at that instant has the document. It says nothing about whether that node stays primary.

Grepping the primary's own log for what happened in that seven-second window:

mongod.log, az-1 primary, 02:14:00-02:14:12 UTC
$ grep -h "REPL\|ROLLBACK" /var/log/mongodb/mongod.log | sed -n '/02:14:0[0-9]/,/02:14:12/p'
{"t":"2026-10-31T02:14:03.102Z","c":"REPL","msg":"Could not find member to sync from"}
{"t":"2026-10-31T02:14:03.104Z","c":"REPL","msg":"Standing for election","attr":{"reason":"heartbeat timeout"}}
{"t":"2026-10-31T02:14:09.881Z","c":"REPL","msg":"Replica set state transition","attr":{"newState":"SECONDARY"}}
{"t":"2026-10-31T02:14:09.912Z","c":"ROLLBACK","msg":"Starting rollback","attr":{"lastCommonPointOpTime":"02:14:02.998Z"}}
{"t":"2026-10-31T02:14:11.236Z","c":"ROLLBACK","msg":"Rollback summary","attr":{"docsDeleted":266,"namespace":"ecommerce.orders"}}

The maintenance host reboot didn't touch the primary directly. It touched a secondary in az-2, and a route table change that shipped in the same window cut cross-AZ connectivity between the primary and both other members for roughly eight seconds. The two az-2 members, unable to reach the primary, held an election between themselves once their heartbeat timeout fired and picked a new primary. The old primary, still physically healthy and still accepting local writes, hadn't yet detected it had lost the majority. For those seven seconds it was a primary with no followers, happily acknowledging w: 1 inserts that no other node would ever see.

When it finally noticed, it stepped down and rejoined as a secondary. Syncing with the new primary, it found its own oplog had entries past the last point the two oplogs agreed on, 02:14:02.998Z, entries the new primary had never received. Per MongoDB's rollback algorithm, those entries get reverted: the affected documents are written out to BSON files under the data directory's rollback/ folder, then deleted from the live collection to bring it back in sync with the new primary's history.

confirming the rollback files match the missing orders
$ ls /var/lib/mongo/rollback/ecommerce.orders.*.bson
/var/lib/mongo/rollback/ecommerce.orders.3f9a1c2e-88b1-4a47-9c3e-2d7a6b901234.bson

$ bsondump /var/lib/mongo/rollback/ecommerce.orders.3f9a1c2e-*.bson | jq -r '._id' | wc -l
     266

$ bsondump /var/lib/mongo/rollback/ecommerce.orders.3f9a1c2e-*.bson | jq -r '._id' | sort > rolled_back.txt
$ comm -12 rolled_back.txt zendesk_affected_order_ids.txt | wc -l
     266

the find

266 documents, exact match against the Zendesk list. The replica set hadn't lost a single request, by the metric anyone was watching. It had lost something else: the guarantee that an acknowledged write stays acknowledged once the node that acknowledged it stops being the primary. w: 1 trades that guarantee for latency, on purpose, and that trade is fine for data you can afford to lose. Nobody had checked whether "confirmed, charged, emailed" still qualified once the setting moved off its default six weeks earlier.


the fix

The fix is putting the write concern back to majority for this one write path, and only this one. w: 1 stays in place for writes that genuinely don't need the guarantee, like updating a product view counter, where losing a few increments during a once-a-year network blip is a non-event.

checkout-api/createOrder.ts, after
const result = await db.collection('orders').insertOne(orderDoc, {
  writeConcern: { w: 'majority', wtimeout: 5000 },
});
// now blocks until a majority of the set has the write durably,
// so a step-down can't un-happen it out from under us

w: 'majority' only returns once at least two of the three members have the write on disk, which means the write exists on whichever node ends up winning any subsequent election. A primary can still lose majority contact and step down, but it can't acknowledge a write that the new primary doesn't also have. The p50 write latency for order creation went from 8ms to 17ms. The p99 went from 22ms to 61ms, almost entirely cross-AZ round trip. Both numbers were acceptable for a write that happens once per order, not once per request.

Recovering the 266 orders turned out to need more than just replaying the BSON files. Fifteen of them had already been swept by a separate, unrelated disk-space cron that purges anything under /var/lib/mongo/ older than 24 hours, a job nobody on the database team had cross-checked against what lives in rollback/. Those fifteen got reconstructed by joining the order.confirmed Kafka events, which still had the full order payload, against Stripe's captured-charge records for the same customer and amount. The other 251 came straight back from the BSON files with a one-off restore script.


the aftermath

266 confirmed orders rolled back out of the live collection in 7 seconds
$0 customer financial loss once recovery finished, all charges were real and all orders were restored

No customer lost money and no charge went unfulfilled, but it took 51 minutes from the first Zendesk ticket to the last order restored, and all of it was avoidable if the write concern change six weeks earlier had come with a one-line question: what happens to this specific write during an election.

  • A clean failover and a correct failover aren't the same claim. Replica set elections are built to keep the cluster available; they say nothing about which acknowledged writes survive the transition if the acknowledgment came from a node that didn't keep its majority.
  • Write concern is a per-operation decision, not a collection-wide default to set once and forget. The right call for a view counter and the right call for a paid order are different calls, and changing one without auditing the other writes sharing that code path is how this kind of thing slips through review.
  • MongoDB's rollback files are a real recovery path, but they're not a backup. They live on local disk, under a path that looks, to any generic cleanup job, exactly like garbage. They got excluded from the disk-space cron and are now synced to S3 immediately after any rollback event, not relied on in place.
  • A CloudWatch metric filter on "ROLLBACK" in the mongod logs now pages on its own, independent of error rate or request success. The maintenance ticket would have reopened itself eight hours earlier instead of waiting for customers to notice.

The maintenance window really was handled correctly, in the sense the team originally meant: the cluster stayed up, requests kept succeeding, nothing timed out. That was always the wrong question. Availability was never what broke.

Share this
← All Posts8 min read