MongoDB guarantees that a write to a single document is atomic. Either the whole update is applied or none of it is, and no reader ever sees the document half-changed. That guarantee covers everything inside the document: nested fields, arrays, arrays of subdocuments. It’s the reason so much MongoDB data modelling advice says “put things that change together in one document”.
What the guarantee doesn’t do is protect logic that lives in your application. Most concurrency bugs in MongoDB code come from reading a document, deciding something in JavaScript or Java, and writing back. This guide is about closing that gap.
The examples are mongosh for MongoDB 7 and 8. They weren’t run against a live server while writing, so treat outputs as illustrative.
What “atomic” covers
Take an order with line items and a running total:
db.orders.insertOne({
_id: 1001,
status: "open",
items: [{ sku: "A1", qty: 1, price: 30 }],
total: 30,
version: 1
})This update adds an item, bumps the total and increments a version in one operation:
db.orders.updateOne(
{ _id: 1001 },
{
$push: { items: { sku: "B7", qty: 2, price: 10 } },
$inc: { total: 20, version: 1 }
}
)No other reader or writer can see the order with the new item but the old total. If the server crashed midway, the document would not end up half-updated. That’s single-document atomicity, and it needs no transaction.
updateMany is different: each matched document is updated atomically, but the operation as a whole isn’t. If you update 10,000 documents and the primary steps down after 6,000, the first 6,000 stay changed. Readers can also see some documents updated and others not while it runs. Interviewers like asking about this because it’s easy to assume “one command, one atomic unit”.
The lost update problem
Here’s the bug pattern. Two requests try to reserve the last seat on a flight at the same time:
// Request A and Request B both run this
const flight = db.flights.findOne({ _id: "LH400" }) // seatsLeft: 1
if (flight.seatsLeft > 0) {
db.flights.updateOne({ _id: "LH400" }, { $set: { seatsLeft: flight.seatsLeft - 1 } })
// ...create booking
}Both reads see seatsLeft: 1. Both pass the check. Both write seatsLeft: 0. Two bookings exist for one seat, and the counter looks perfectly fine. Each individual write was atomic; the race happened in the gap between read and write.
The fix is to move the check into the update filter, so the condition and the change happen in one atomic step:
const res = db.flights.updateOne(
{ _id: "LH400", seatsLeft: { $gt: 0 } },
{ $inc: { seatsLeft: -1 } }
)
// winner: { acknowledged: true, matchedCount: 1, modifiedCount: 1 }
// loser: { acknowledged: true, matchedCount: 0, modifiedCount: 0 }Only one request can match the filter while seatsLeft is 1. The second request’s filter finds no document, so nothing changes. Your code must look at matchedCount (or modifiedCount) and treat 0 as “sold out”. Ignoring the result is the second half of this bug: the update didn’t fail with an error, it just didn’t match.
Notice also $inc instead of $set with a computed value. Update operators like $inc, $push, $addToSet, $pull and $min/$max compute the new value on the server from the current value, so they never write back a stale number.
Getting the document back: findOneAndUpdate
Often you need the result, for example to show the remaining seats or to claim a job from a queue. findOneAndUpdate does the match, update and return in one atomic step:
db.jobs.findOneAndUpdate(
{ status: "queued" },
{ $set: { status: "running", worker: "w-3", startedAt: new Date() } },
{ sort: { priority: -1, _id: 1 }, returnDocument: "after" }
)Ten workers can run this at once and each job is claimed by exactly one of them. A worker that finds nothing gets null. This pattern is the standard answer to “how would you build a simple job queue on MongoDB”.
Optimistic concurrency with a version field
Sometimes the change can’t be expressed with operators, say a user edits a whole profile form and you replace many fields. Add a version number and require it in the filter:
const res = db.profiles.updateOne(
{ _id: userId, version: 7 },
{ $set: { bio: newBio, location: newLocation }, $inc: { version: 1 } }
)
if (res.matchedCount === 0) {
// someone else saved first: reload and show a conflict
}If another save already moved the version to 8, this update matches nothing and you know to reload rather than overwrite their changes. That’s optimistic locking, and it costs nothing more than an extra field.
Upserts and the duplicate-key race
updateOne(filter, update, { upsert: true }) inserts when nothing matches. Two concurrent upserts with the same filter can both find no match and both try to insert. If a unique index covers the filter field, one insert succeeds and the other fails with E11000 duplicate key error. If there’s no unique index, you can end up with two documents. So: put a unique index on whatever upserts match on, and retry once on duplicate key, because the retry will now find the document and update it. Since 4.2 the server retries some of these upsert conflicts internally when the filter is an equality match on the unique key, but you shouldn’t rely on that without the index.
Where atomicity stops
Single-document atomicity stops at the document boundary. Moving money between two account documents, decrementing stock in inventory while inserting into orders, or updating a user and their separate settings document are all multi-document changes. For those you have three options:
- Remodel so the data that must change together lives in one document (often the best answer).
- Use a multi-document transaction (available on replica sets and sharded clusters).
- Design an idempotent sequence of single-document steps with a status field you can resume or compensate from.
Documents also have a 16 MB limit, so “embed everything to get atomicity” fails for unbounded growth like a chat room’s messages.
Interview practice
You run a coupon system. Each coupon document has { code, maxUses, uses }. The current redeem code reads the coupon, checks uses < maxUses, then does $inc: { uses: 1 }. In production, a coupon with maxUses: 100 ended with uses: 104. Explain why and fix it without a transaction.
Answer
Each $inc was atomic, so the counter itself is correct: there really were 104 increments. The problem is the check. It ran in application code on a value read earlier. Under load, several requests read uses: 99, all passed 99 < 100, and all incremented. The read and the write weren’t one atomic step.
The fix puts the condition in the filter. Since the limit lives in the same document, use $expr to compare two fields:
const res = db.coupons.updateOne(
{ code: "FALL25", $expr: { $lt: ["$uses", "$maxUses"] } },
{ $inc: { uses: 1 } }
)
if (res.matchedCount === 0) throw new Error("coupon exhausted or unknown")Now the server evaluates uses < maxUses and increments in one operation on that document, so the counter can’t pass 100. I’d mention that if redeeming also has to write a redemptions record, either embed recent redemptions in the coupon (if bounded) or accept a two-step flow where the coupon increment is the gate and the redemption insert is idempotent, keyed by order id. A transaction is the fallback, not the first tool.