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

Atomic operations without transactions, explained simply

Single-document writes are atomic.

Expona Admin7 min read

Atomic operations without transactions, explained simply

MongoDB guarantees that single-document writes are atomic—meaning they complete entirely or not at all, with no partial updates visible to other operations. This atomic behaviour without multi-document transactions is one of MongoDB's most practical features, and understanding how to leverage it can eliminate entire classes of race conditions in your application. In this post, we'll explore how to structure your data and queries so that atomic operations without transactions handle the consistency requirements most applications actually need.

Why atomicity matters in production

Imagine a ride-hailing system where a driver accepts a ride request. The request document needs to update the driver's status to "assigned", update the ride status to "accepted", and add the driver's ID to the ride. If those three updates happen separately, a second driver might read the ride as still available and also accept it—creating a double-booking.

MongoDB's single-document atomicity guarantee means that if all that data lives in one document, all updates either succeed together or fail together. No race condition. No intermediate state visible to other clients. This is the foundation of building consistent systems without the performance cost of multi-document transactions.

The first principle is to structure your documents so that data that must change together lives in the same document. This is the inverse of relational normalization—we denormalize intentionally.

For our ride-hailing scenario, instead of separate collections for rides and driver assignments, we embed the assignment status inside the ride document:

javascript

{
  _id: ObjectId("..."),
  requesterId: ObjectId("..."),
  pickupLocation: { type: "Point", coordinates: [-73.97, 40.77] },
  status: "requested",
  assignedDriver: null,
  driverStatus: null,
  createdAt: ISODate("2024-01-15T10:00:00Z")
}

When a driver accepts, we update both the ride status and assignment in one atomic operation:

javascript

const result = await db.collection('rides').updateOne(
  { _id: ObjectId("..."), status: "requested", assignedDriver: null },
  {
    $set: {
      status: "accepted",
      assignedDriver: driverId,
      driverStatus: "assigned",
      acceptedAt: new Date()
    }
  }
);

if (result.modifiedCount === 0) {
  // Another driver or the system already handled this ride
  throw new Error('Ride already assigned');
}

Notice the conditions in the filter: we check both that the status is "requested" and assignedDriver is null. This prevents the second driver from succeeding because the filter won't match after the first update. This is how we prevent race conditions without transactions.

Use $inc for counters

Counters are a common source of data loss in distributed systems. When you read a count, increment it, then write it back, you have a race condition. MongoDB's $inc operator is atomic—it increments the field value on the server, with no read-modify-write cycle exposed to race conditions.

In an e-commerce platform, you might track available inventory in a product document:

javascript

{
  _id: ObjectId("..."),
  sku: "WIDGET-001",
  name: "Blue Widget",
  priceUsd: 29.99,
  stockAvailable: 150,
  stockReserved: 45,
  totalOrdered: 2847
}

When an order is placed, we decrement available inventory and increment total ordered—both atomically:

javascript

const result = await db.collection('products').updateOne(
  { _id: productId, stockAvailable: { $gte: quantityOrdered } },
  {
    $inc: {
      stockAvailable: -quantityOrdered,
      stockReserved: quantityOrdered,
      totalOrdered: quantityOrdered
    }
  }
);

if (result.modifiedCount === 0) {
  throw new Error('Insufficient stock');
}

The condition stockAvailable: { $gte: quantityOrdered } ensures we only decrement if stock exists. If another order already consumed that inventory, the update fails and we know instantly. No lost updates, no overselling.

This pattern scales to hundreds of concurrent orders because $inc is atomic at the server level—MongoDB applies each increment sequentially. Your application code never reads the intermediate value.

Use findOneAndUpdate for read-and-modify patterns

Sometimes you need to read the current state, make a decision, and write back—all atomically. The findOneAndUpdate operation does exactly this: it matches a document, updates it, and returns either the old or new version, all in one atomic step.

In a SaaS billing system, when a customer's usage crosses a threshold, we might upgrade their plan automatically:

javascript

const result = await db.collection('customers').findOneAndUpdate(
  { _id: customerId, planId: "starter", monthlyApiCalls: { $gte: 100000 } },
  {
    $set: {
      planId: "professional",
      upgradeReason: "usage_limit_exceeded",
      upgradedAt: new Date()
    },
    $inc: { monthlyApiCalls: 1 }
  },
  { returnDocument: "after" }
);

if (!result.value) {
  // Either customer not found, already upgraded, or usage threshold not met
  console.log('No upgrade needed');
} else {
  console.log('Customer upgraded:', result.value);
  // Send email notification, emit event, etc.
}

The key is that we read the document's state, decide to upgrade based on that state, and perform all the updates—all in one operation. No other request can read the old plan during our decision-making process.

Return the after document so your code can immediately see the updated state and take action (send an email, log the event) without needing a separate read.

Conditions in filters prevent race conditions

The pattern across all these examples is the same: put conditions in your filter that enforce your business rules, then use $set, $inc, or other operators to make your changes. If the condition is no longer true, the update fails—and you know why.

This is far simpler than multi-document transactions for most real-world scenarios. A transaction would require you to begin, check conditions, write, and commit—more code, more latency, more complexity. A conditional atomic write on a single document is faster and just as safe.

Consider a CRM system where you're updating a deal's stage and potentially locking it so no one else can change it while a notification is being sent:

javascript

const result = await db.collection('deals').updateOne(
  {
    _id: dealId,
    stage: "negotiation",
    lockedBy: null,
    lockedUntil: { $lt: new Date() }
  },
  {
    $set: {
      stage: "closed_won",
      closedAt: new Date(),
      lockedBy: userId,
      lockedUntil: new Date(Date.now() + 30000) // 30 seconds
    }
  }
);

if (result.modifiedCount === 0) {
  throw new Error('Deal is either in a different stage, already locked, or lock has expired');
}

The filter ensures the deal is in "negotiation" stage, not already locked, and any previous lock has expired. Only then do we move to "closed_won" and lock it. If any condition fails, the update returns 0 modified documents and we know the operation was not safe to proceed.

Common mistakes

Mistake 1: Forgetting to check modifiedCount. Always inspect matchedCount and modifiedCount in your update results. A zero means your conditions weren't met—don't assume the update succeeded. Your code must handle the failure case explicitly.

Mistake 2: Updating without conditions. If you write db.collection('users').updateOne({ _id: userId }, { $inc: { balance: -50 } }) without checking that balance is sufficient first, you can create negative balances. Always include your business rule in the filter.

Mistake 3: Over-embedding. While embedding data makes atomic updates possible, embedding too much creates large documents that are slow to update and transmit. Embed only the data that truly needs to change together. Keep historical or rarely-updated data separate.

Mistake 4: Confusing atomicity with isolation. A single-document write is atomic, but if you're reading from that document in one operation and writing in another, you don't have isolation between them unless you use transactions. Atomic operations without transactions work best when all related data lives in one document.

Key takeaways

  • Single-document writes in MongoDB are atomic—all changes complete or none do, with no partial state visible to other operations.
  • Structure documents so related data lives together; denormalize intentionally so atomic updates cover all your consistency needs.
  • Use $inc for counters to avoid read-modify-write race conditions; the server applies increments sequentially and atomically.
  • Use findOneAndUpdate when you need to read, decide, and write based on current state—all in one atomic operation.
  • Put your business rules in the filter, not in your application logic; let MongoDB tell you if the conditions were met before updating.
  • Always check modifiedCount to confirm your conditions were satisfied; treat a zero as a signal to retry or fail gracefully.
  • Atomic operations without transactions are faster and simpler than multi-document transactions for most real-world consistency requirements.
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