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

MongoDB transactions: a practical MongoDB & NoSQL guide

Multi-document ACID when you need it.

Expona Admin9 min read

MongoDB transactions: a practical MongoDB & NoSQL guide

Multi-document ACID guarantees were a game-changer when MongoDB introduced them in version 4.0, but they're not a silver bullet. We've seen teams burn resources holding long-running transactions or wrap single-document updates in transactions when denormalization would have been simpler. This guide walks you through the when, how, and gotchas of MongoDB transactions so you can use them confidently in production.

Understanding ACID guarantees in NoSQL

Traditional relational databases guarantee ACID properties across multiple rows. MongoDB brought that capability to document databases, but with important differences. A transaction in MongoDB ensures that multiple documents across collections either all commit or all roll back together—no partial writes. This is powerful when your business logic depends on coordinated changes, but it comes at a cost in complexity and resource usage.

The key insight: transactions are a safety net for situations where your data model can't safely express the operation as a single atomic update. Before reaching for transactions, ask yourself whether you could embed related data into a single document or use a different modeling approach.

When to use transactions: Updates across documents that must succeed together

Real-world example: an e-commerce order system. When a customer places an order, you need to:

  • Create an order document in the orders collection
  • Decrement inventory in the products collection
  • Create a payment record in the payments collection
  • Update customer account balance in the users collection

If any step fails after others succeed, you've broken your system. The customer's balance was deducted but no order exists, or inventory was reduced but payment never processed. Transactions guarantee that either all four operations commit or none of them do.

Another scenario: account transfers in a financial application. Money must leave one account and enter another in lockstep. A transaction prevents the scenario where funds disappear into the void or duplicate.

But here's the constraint: transactions in MongoDB only work with replica sets (including sharded clusters, where they coordinate across shards). Standalone instances don't support multi-document transactions. This is an important architectural consideration—if you're building a production system, you need replication anyway, but it's worth knowing upfront.

Use sessions: Start a session and run operations within a transaction

Every MongoDB transaction runs within a session. A session maintains context across multiple operations, allowing MongoDB to track which operations belong together. Here's the practical pattern:

javascript
const session = db.getMongo().startSession();
session.startTransaction();

try {
  const orderCollection = db.getCollection('orders');
  const productCollection = db.getCollection('products');

  // Insert order
  const orderResult = orderCollection.insertOne(
    {
      customerId: 'cust_123',
      status: 'pending',
      total: 150,
      items: [
        { productId: 'prod_456', qty: 2, price: 75 }
      ]
    },
    { session }
  );

  // Decrement inventory
  const updateResult = productCollection.updateOne(
    { _id: 'prod_456' },
    { $inc: { stock: -2 } },
    { session }
  );

  session.commitTransaction();
} catch (error) {
  session.abortTransaction();
  throw error;
} finally {
  session.endSession();
}

Notice that every operation—insertOne, updateOne, deleteOne, etc.—accepts a session option. Without passing the session, the operation runs outside the transaction. This is a common source of bugs; it's easy to forget the session parameter on one operation and wonder why your transaction semantics aren't working.

In a driver like Node.js MongoDB driver, the pattern looks similar:

javascript
const session = client.startSession();

try {
  await session.withTransaction(async () => {
    const ordersDb = client.db('shop').collection('orders');
    const productsDb = client.db('shop').collection('products');

    const order = {
      customerId: 'cust_123',
      total: 150,
      createdAt: new Date()
    };

    const orderInsert = await ordersDb.insertOne(order, { session });
    const inventoryUpdate = await productsDb.updateOne(
      { _id: 'prod_456' },
      { $inc: { stock: -2 } },
      { session }
    );

    return { orderInsert, inventoryUpdate };
  });
} finally {
  await session.endSession();
}

The withTransaction helper abstracts session and commit/abort logic, which is cleaner. If an exception is thrown inside the callback, the transaction automatically aborts. If execution completes without throwing, it commits.

One more critical detail: transactions are per-session, and a session can only run one transaction at a time. If you need parallel transactions, you need multiple sessions.

Keep them short: Long transactions hold resources and can abort

MongoDB transactions hold locks on the documents they modify. Long-running transactions block other writers and consume server resources. The default transaction timeout is 30 seconds—exceed that and MongoDB aborts your transaction automatically.

Here's what happens in production: you write a transaction that looks good in testing (fast, single replica set), but in production with network latency and concurrent load, it creeps over 30 seconds. The transaction aborts, and your application crashes or retries in a loop. We've seen this pattern cause cascading failures.

Guidelines:

  • Do your reads and computations before the transaction starts
  • Keep the transaction focused on writes only
  • Avoid calling external APIs, databases, or services inside a transaction
  • Keep transactions under 5 seconds in typical production environments

Example of a poorly designed transaction:

javascript
// DON'T do this
await session.withTransaction(async () => {
  const order = await ordersDb.findOne({ _id: orderId }, { session });
  
  // Calling external service inside transaction—slow!
  const shippingCost = await calculateShipping(order.address);
  
  // Long computation
  const taxAmount = computeTaxes(order, state);
  
  // Heavy aggregation
  const inventoryStatus = await productsDb.aggregate(
    [...],
    { session }
  ).toArray();

  // Finally update
  await ordersDb.updateOne(
    { _id: orderId },
    { $set: { status: 'confirmed', shipping: shippingCost } },
    { session }
  );
});

Better approach:

javascript
// DO this
const order = await ordersDb.findOne({ _id: orderId });
const shippingCost = await calculateShipping(order.address);
const taxAmount = computeTaxes(order, state);
const inventoryStatus = await productsDb.aggregate([...]).toArray();

// Transaction only handles writes
await session.withTransaction(async () => {
  await ordersDb.updateOne(
    { _id: orderId },
    { 
      $set: { 
        status: 'confirmed', 
        shipping: shippingCost,
        tax: taxAmount 
      } 
    },
    { session }
  );
});

The difference is dramatic: the second example keeps the transaction window tiny, reducing contention and the risk of timeout.

Model first: Often a single-document update makes transactions unnecessary

Before you reach for transactions, reconsider your schema. Many use cases for transactions can be solved with smarter data modeling—and you'll get better performance.

Example: the order scenario above. Instead of splitting data across orders, products, and payments, you could embed the order items and payment status directly in the order document:

javascript
db.orders.insertOne({
  _id: ObjectId(),
  customerId: 'cust_123',
  status: 'confirmed',
  total: 150,
  items: [
    { productId: 'prod_456', qty: 2, price: 75, name: 'Widget' }
  ],
  payment: {
    status: 'captured',
    amount: 150,
    method: 'card',
    transactionId: 'txn_789'
  },
  createdAt: new Date()
});

This is a single atomic write. No transaction needed. The order and its details are guaranteed to be consistent because they're a single document. You still need to handle inventory separately, but that's a simpler problem: a simple decrement operation on the product document, which is also atomic.

The tradeoff: embedding means duplicating product names and prices in the order. If product prices change later, old orders keep their historical prices (which is usually what you want anyway). You lose the ability to update all orders if a product name changes, but that's rarely a business requirement.

Another pattern: use a status field and idempotency keys to handle partial failures gracefully without transactions.

javascript
db.orders.insertOne({
  _id: ObjectId(),
  customerId: 'cust_123',
  status: 'pending', // Will be updated to 'confirmed' once all steps succeed
  idempotencyKey: 'idempotency_abc123', // Prevents duplicate orders on retry
  items: [...],
  payment: { status: 'pending' }
});

// Step 1: Record payment
await paymentsDb.insertOne({ orderId: orderId, status: 'captured', ... });

// Step 2: Decrement inventory (idempotent)
await productsDb.updateOne(
  { _id: 'prod_456', 'orders.idempotencyKey': { $ne: 'idempotency_abc123' } },
  { $inc: { stock: -2 }, $push: { orders: { idempotencyKey: 'idempotency_abc123' } } }
);

// Step 3: Mark order complete
await ordersDb.updateOne(
  { _id: orderId },
  { $set: { status: 'confirmed' } }
);

This approach is more resilient than transactions: if a step fails, retry it. The idempotency key ensures it's safe to retry. No resource locks, no timeouts.

Common mistakes

Forgetting to pass the session parameter: The most common error. An operation without { session } runs outside the transaction, breaking consistency guarantees. Code review should catch this, but it's worth emphasizing.

Running transactions on standalone instances: Transactions require a replica set. If your local development uses a standalone MongoDB instance, your code works in dev but fails in production. Use a replica set everywhere, even locally (Docker Compose helps).

Holding transactions across await statements on I/O: If you await an API call, database query to another system, or file operation inside a transaction, you're asking for timeouts. Read and compute outside; transact inside.

Retrying without exponential backoff: When a transaction aborts due to conflict or timeout, your application should retry with exponential backoff. Hammering the server immediately wastes resources and increases the chance of another abort.

Not handling the WriteConflict error: In high-concurrency scenarios, two transactions can conflict. MongoDB throws a WriteConflict error. Your code should catch it, abort the session, and retry the entire transaction with fresh state.

Key takeaways

  • Use transactions only when multiple documents across collections must change together atomically
  • Transactions require a replica set; they won't work on standalone instances
  • Always pass the session parameter to database operations, or they'll run outside the transaction
  • Keep transactions short—do reads and computations before starting, do only writes inside
  • Model your data to avoid transactions whenever possible; embedding and status fields are often simpler and faster
  • Handle transaction aborts gracefully with retry logic and exponential backoff
  • In development, use a replica set (even local Docker) to catch transaction bugs before production
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