This article is published in English.
Preventing Lost Updates in Node.js and MongoDB Under Concurrent Writes
Learn how atomic conditional updates, version-based optimistic locking, 409 responses and transactions stop concurrent MongoDB writes from silently discarding data.
Two people press Save on the same record, both requests return 200 OK, and one person's change quietly disappears. Nothing crashes, nothing is logged, and the code responsible looks perfectly reasonable when read one request at a time. This article explains why these lost updates happen in a typical Node.js and MongoDB backend, and gives you a toolkit for preventing them: atomic conditional updates, version-based optimistic locking, 409 Conflict responses, transactions, and tests that actually reproduce the race.
A realistic scenario
Take an admin dashboard for an online shop. One product currently has a price of $100 and 10 units in stock.
An employee in the US opens the product and lowers the price to $90. Almost simultaneously, a colleague in Europe opens the same product and sets the stock to 8. Both loaded the product before either of them saved, so both are editing the same stale snapshot. That shared starting point is where the trouble begins.
The read-modify-write gap
A common way to implement each edit is to load the document, change a property in memory and save it. The first request changes the price. (The snippet as shown carries a stray duplicated product.price = 90; after the save() call; ignore it, as it only illustrates the pattern.)
const product = await Product.findById(productId);
product.price = 90;
await product.save();product.price = 90;
The second request does the same thing for the stock field:
const product = await Product.findById(productId);
product.stock = 8;
await product.save();
Each request reads the old state, modifies its local copy and writes it back. findById() is not the culprit. The danger lives in the window between reading the data and writing it back, because another request can change the same record inside that window. Whatever is written last wins, and any change made in between can be overwritten: a lost update.
How much of this Mongoose already handles
It is worth being precise about when this bites. When you call save() on an existing Mongoose document, Mongoose sends only the paths you modified, as a $set. So in the exact example above, where the two requests touch different fields, the price and stock edits would usually both survive. The lost update becomes real when:
- both requests change the same field (two admins editing the price);
- the new value is computed from the old one (
product.stock = product.stock - 1), so the second writer works from a stale number; - your API accepts the whole document from the client, as in a typical
PUThandler, and writes every field back, including fields another user just changed.
Other ODMs, raw drivers and SQL ORMs behave differently, so do not rely on dirty-tracking as a concurrency strategy. Treat read-modify-write as unsafe by default and choose one of the tools below deliberately.
Start from the invariant
Before you pick a technique, decide what must never become wrong. The answer differs by domain:
- For inventory: stock must never go below zero.
- For an admin editor: one person's changes must never silently replace another's.
- For money: related balance changes must stay consistent with each other.
That business rule, not a favorite pattern, should determine the technical solution.
Atomic updates: let the database do the change
If you are changing a single field, you often do not need to read the document at all. Send the database exactly the change you intend. Setting the price becomes one updateOne with a $set:
await Product.updateOne(
{ _id: productId },
{
$set: {
price: 90
}
}
);
The stock edit is equally independent:
await Product.updateOne(
{ _id: productId },
{
$set: {
stock: 8
}
}
);
Each operation now describes its real intent instead of shipping an old copy of the document back to the server. MongoDB applies every single-document update atomically, so two such operations on different fields cannot clobber each other.
Put the business rule inside the update
The pattern gets more powerful when you fold the condition into the query. Suppose one ticket is left. The naive flow reads the stock, checks it in application code and then decrements it, which leaves a gap for another buyer to slip through. Instead, make the filter express the rule, and let $inc do the change in the same operation:
const result = await Product.updateOne(
{
_id: productId,
stock: { $gt: 0 }
},
{
$inc: {
stock: -1
}
}
);
If the update modifies a document, stock was available at the instant the operation ran. If it modifies nothing, some other request has already taken the last unit, and you can tell the user it sold out. There is no window between check and write because they are the same step. This is one of the simplest and most effective concurrency patterns available, and it works for counters, quotas, seat reservations and any rule you can express as a query filter.
Optimistic locking for long-lived edits
Atomic updates cannot cover every case. Imagine an employee opening a large product configuration, spending five minutes adjusting several fields and then saving. Meanwhile a colleague has already saved changes to the same product. The first employee should not overwrite the newer version without knowing it exists.
The standard fix is a version number stored on the document:
Product
Price: $100
Stock: 10
Version: 7
Both users load version 7. User A saves first, and the version becomes 8. User B still holds version 7, so B's save must mean "apply this only if the product is still at version 7". In MongoDB you express that by putting the expected version in the filter and incrementing it in the same update:
const result = await Product.updateOne(
{
_id: productId,
version: currentVersion
},
{
$set: {
price: newPrice
},
$inc: {
version: 1
}
}
);
if (result.modifiedCount === 0) {
return res.status(409).json({
message: "This product was updated by another user."
});
}
If the filter no longer matches, nothing is written and the handler returns a conflict instead of silently discarding A's work. This is optimistic concurrency control: you assume conflicts are rare, do not hold any locks while the user edits, and detect a clash at write time.
Two refinements make this sturdier in practice. First, modifiedCount === 0 is also what you get when the product does not exist at all, so checking matchedCount, or following up with a lookup, lets you return 404 for a missing product and 409 only for a genuine version clash. Second, if you use Mongoose documents rather than updateOne, look at the schema-level optimisticConcurrency option; Mongoose's built-in __v key is otherwise only used to protect certain array operations, not every save.
Why the right status is 409 Conflict
A version mismatch is not a server failure. The API is healthy and the request is well formed; it simply conflicts with the current state of the resource. 409 Conflict communicates exactly that, and it lets the client react sensibly:
- reload the latest version;
- show the user what changed since they started;
- let them merge their edits or retry;
- apply conflict handling specific to the product.
Whatever the UI does, the principle is the same: never destroy someone else's work without telling anyone.
Transactions: when several writes must succeed together
Now consider placing an order. It might involve creating the order document, reserving inventory and writing related records such as a payment or audit entry. If the first two succeed and the third fails, the system is left in a half-finished business state.
When several operations must all commit or all fail, a transaction gives you that atomicity. Conceptually you begin a transaction, perform the related writes and commit; if any required step fails, you roll back and none of the writes take effect. In MongoDB, multi-document transactions require a replica set or sharded cluster and run through a client session.
Transactions are not a universal cure for races, though. They cost more, can abort under write conflicts and need retry logic. Use them where the business operation genuinely requires all-or-nothing consistency, and prefer a single conditional update where one suffices.
Choosing the right tool
Rather than starting with "should we use optimistic locking?", start with "which failure are we preventing?":
- A single-field or conditional change, such as decrementing stock only when it is available: use an atomic update.
- Stale edits from users working on old data, such as two admins editing one product: use optimistic concurrency control.
- Several writes that must succeed or fail together, such as order, inventory and account changes: use a transaction.
- Very high contention on the same data: consider pessimistic locking, queuing the work or partitioning it, depending on the workload.
No single pattern fits every system, and many real features combine two of them.
Reproduce the race in tests
A test in which user A updates a product and gets a success response proves nothing about concurrency. Normal tests run operations one after another, which is exactly why these bugs survive them. You have to create the race in order to test for it.
For the inventory case, start with stock set to 1 and fire 100 purchase attempts concurrently, for example with Promise.all. The expected outcome is that exactly one reservation succeeds and the other 99 are rejected cleanly, with stock ending at zero rather than negative.
For optimistic locking, set the version to 10 and send several updates that all claim version 10. You should see one succeed and bump the version, while the rest receive conflicts instead of overwriting the newer data.
What to watch in production
After deployment, make these signals visible in your metrics and logs:
409 Conflictresponses;- conditional updates that matched nothing;
- transaction retries and aborts;
- deadlocks and lock contention;
- unexpected inventory movements;
- duplicate operations;
- other concurrency-related errors.
A sudden rise in conflicts often points at something deeper: a hot record, an unusual traffic pattern, clients retrying too aggressively, or a new feature generating more contention than anyone expected. Duplicate operations in particular are often better handled with idempotency keys, covered in our guide to idempotent POST endpoints.
Concurrency is the normal case
Races are not really about two humans clicking at the same moment. They arise whenever several actors can modify shared state: users, API instances, background workers, queue consumers, scheduled jobs, webhooks and other services. At any meaningful scale, concurrent access is routine rather than exceptional.
So do not ask whether two requests could reach the same code at once; assume they will. A useful review habit is to ask of every update what the outcome would be if two copies of it executed simultaneously. If the design answers that clearly, you are in good shape. If the honest answer is "hopefully the second request is harmless", the code needs another look.
Key takeaways
- A lost update happens when one valid change is overwritten by another written from stale data, typically through read-modify-write.
- Prefer atomic, conditional updates that encode the business rule in the filter.
- Use a version field and a
409 Conflictresponse to detect stale edits instead of overwriting them. - Reach for transactions only when multiple writes must commit or roll back together.
- Test with genuinely concurrent requests and monitor conflicts in production; design for the overlapping request that can happen, not only the one you expect.