The problem of lost updates
In distributed systems and multi-user applications, concurrent updates to the same document can silently overwrite each other. Imagine two team members editing an order status simultaneously—one adds a tracking number, the other updates the shipment date. Without proper safeguards, one of those changes disappears forever. This is the lost update problem, and it's one of the most common data integrity issues we encounter in production MongoDB systems.
Optimistic concurrency in MongoDB: a step-by-step guide walks you through a battle-tested pattern to prevent exactly this scenario. Rather than locking documents (which MongoDB doesn't natively support), we use versioning to detect conflicting updates and let the application layer decide how to handle them. This approach keeps throughput high, scales horizontally, and fits naturally into most application architectures.
Add a version field
The foundation of optimistic concurrency is a version field—a simple integer that increments with every successful update. When you first create a document, set the version to 1. When that document is fetched for editing, the application captures both the data and the version number.
Here's a typical document structure:
{
_id: ObjectId("507f1f77bcf86cd799439011"),
orderId: "ORD-2024-001",
status: "pending",
customerId: "CUST-5432",
items: [
{ sku: "WIDGET-A", quantity: 5, price: 29.99 }
],
totalAmount: 149.95,
version: 1,
createdAt: ISODate("2024-01-15T10:30:00Z"),
updatedAt: ISODate("2024-01-15T10:30:00Z")
}
The version field is your concurrency control mechanism. It's not metadata—it's a real part of your data model and belongs in your schema validation. In a TypeScript application, you'd define it explicitly:
interface Order {
_id?: ObjectId;
orderId: string;
status: "pending" | "shipped" | "delivered";
customerId: string;
items: OrderItem[];
totalAmount: number;
version: number;
createdAt: Date;
updatedAt: Date;
}
Keep the version field lightweight. A 32-bit integer can handle millions of updates to a single document—more than sufficient for most use cases. Store it alongside your business data, not in a separate collection.
Include version in the update filter
This is where the magic happens. When you update a document, you don't just filter by _id. You filter by both _id and the current version number. If another process has incremented the version in the meantime, your filter will match zero documents, and you'll know a conflict occurred.
Suppose your application reads an order with version 3. While your code is processing that order, another process updates it successfully, incrementing the version to 4. Now when your process attempts its update, the filter { _id: orderId, version: 3 } finds nothing because the version is now 4.
Here's a practical example using the MongoDB Node.js driver:
async function updateOrderStatus(orderId, newStatus, currentVersion) {
const collection = db.collection('orders');
const result = await collection.updateOne(
{
_id: orderId,
version: currentVersion // Only match if version hasn't changed
},
{
$set: {
status: newStatus,
updatedAt: new Date()
}
}
);
if (result.matchedCount === 0) {
throw new Error('Document was modified by another process (version mismatch)');
}
return result;
}
The critical check is matchedCount === 0. If no documents matched the filter, it means either the document doesn't exist or its version has changed. The update operation itself succeeded atomically—it just didn't apply to anything, which is exactly what we want when a conflict is detected.
Increment version on update
Every time an update succeeds (when the version filter matches), you must increment the version. Use the $inc operator to do this atomically as part of the same update operation. This ensures that the version and your business data stay in lockstep.
Let's refine the example to include version increment:
async function updateOrderStatus(orderId, newStatus, currentVersion) {
const collection = db.collection('orders');
const result = await collection.updateOne(
{
_id: orderId,
version: currentVersion
},
{
$set: {
status: newStatus,
updatedAt: new Date()
},
$inc: {
version: 1 // Increment version atomically
}
}
);
if (result.matchedCount === 0) {
throw new Error('Version mismatch: document was modified elsewhere');
}
return result;
}
By combining $set and $inc in the same update, MongoDB applies both operations atomically. The version increments by exactly 1 if and only if the filter matched. This atomic operation is critical—if the version incremented separately, another concurrent process could slip in between and cause inconsistency.
Always increment by 1 consistently. Don't use version numbers as timestamps or attempt to encode additional information. Keep them simple: just a counter. If you need to track when changes occurred, use separate fields like updatedAt.
Retry or return a conflict when no match
When the filter doesn't match (version mismatch detected), you have two main strategies: retry with fresh data, or return a conflict response to the caller.
Retry strategy: Re-fetch the document (which includes the new version), apply your intended changes on top of the fresh state, and attempt the update again. This works well for automated processes where conflicts are occasional.
async function updateOrderStatusWithRetry(
orderId,
newStatus,
maxRetries = 3
) {
const collection = db.collection('orders');
for (let attempt = 0; attempt < maxRetries; attempt++) {
// Fetch the latest document and version
const order = await collection.findOne({ _id: orderId });
if (!order) {
throw new Error('Order not found');
}
// Attempt update with current version
const result = await collection.updateOne(
{
_id: orderId,
version: order.version
},
{
$set: {
status: newStatus,
updatedAt: new Date()
},
$inc: {
version: 1
}
}
);
if (result.matchedCount === 1) {
return { success: true, newVersion: order.version + 1 };
}
// Version mismatch, retry
console.log(`Retry attempt ${attempt + 1} for order ${orderId}`);
}
throw new Error(
`Failed to update order after ${maxRetries} attempts`
);
}
Conflict response strategy: Return an HTTP 409 (Conflict) to the client and let them handle the reconciliation. This is often the right choice for user-facing APIs where we want the user to see the latest data and make an informed decision about their update.
async function handleOrderStatusUpdate(req, res) {
const { orderId, newStatus, version } = req.body;
const collection = db.collection('orders');
const result = await collection.updateOne(
{
_id: ObjectId(orderId),
version: version
},
{
$set: {
status: newStatus,
updatedAt: new Date()
},
$inc: {
version: 1
}
}
);
if (result.matchedCount === 0) {
// Return conflict status and fetch fresh data
const freshOrder = await collection.findOne({ _id: ObjectId(orderId) });
return res.status(409).json({
error: 'Conflict: document was modified',
currentData: freshOrder
});
}
res.json({ success: true, newVersion: version + 1 });
}
Choose based on your use case. Retry is better for backend jobs and internal operations. Conflict responses are better for APIs where users make explicit edits and need to see competing changes.
Common mistakes
- Forgetting to include version in the filter: If you update documents by
_idalone and increment version separately, you've lost the safety. The filter must include the version check every single time. - Starting version at 0: While it works, starting at 1 is convention and makes logs easier to read. Version 0 can be confusing when debugging.
- Incrementing version outside the atomic update: If you fetch, update, then increment version in a separate call, you've reintroduced the race condition. Always use
$incas part of the sameupdateOneoperation. - Not handling version mismatch in production code: Assume conflicts will happen. Test your retry or conflict-response logic thoroughly. Don't silently ignore matchedCount === 0.
- Using version as a timestamp or encoding other data: Version is a counter only. Create separate fields for metadata. Mixing concerns makes debugging impossible.
- Applying optimistic concurrency inconsistently: Every update to a versioned document must check and increment the version. If one code path skips it, you'll still have lost updates in that path.
Key takeaways
- Optimistic concurrency prevents lost updates without database-level locks by checking a version field during every update.
- Add a
versionfield to your document schema, initialized to 1 on creation and incremented with each successful update. - Always include
versionin your update filter alongside_id. If the filter matches zero documents, a conflict has occurred. - Use MongoDB's
$incoperator to increment the version atomically as part of the same update operation that changes your business data. - Handle conflicts by either retrying with fresh data (for automated processes) or returning a 409 response (for user-facing APIs).
- Implement this pattern consistently across all code paths that modify versioned documents. Inconsistency defeats the purpose.






Comments
Loading comments…