Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 026 / 100

Optimistic concurrency in MongoDB: a step-by-step guide

Avoid lost updates.

Expona Admin8 min read

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:

javascript

{
  _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:

javascript

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:

javascript

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:

javascript

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.

javascript

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.

javascript

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 _id alone 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 $inc as part of the same updateOne operation.
  • 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 version field to your document schema, initialized to 1 on creation and incremented with each successful update.
  • Always include version in your update filter alongside _id. If the filter matches zero documents, a conflict has occurred.
  • Use MongoDB's $inc operator 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles