MongoDB Transactions

MongoDB Transactions


1. Multi-Document Transactions Overview

MongoDB added multi-document ACID transactions in 4.0 (replica sets) and extended them to sharded clusters in 4.2. Before 4.0, atomicity was limited to single-document operations (which remain the preferred approach for most use cases).

ACID guarantees provided:

Snapshot isolation is the default isolation level since 4.0. Within a transaction, a client sees the data as it existed at transaction start, even if concurrent writers commit changes. This avoids dirty reads and non-repeatable reads but can cause write conflicts (two transactions modifying the same document — the second writer’s commit or a read-write conflict will abort one of them).

// Node.js — conceptual illustration of the ACID boundary
const session = client.startSession();
try {
  session.startTransaction({
    readConcern: { level: "snapshot" },
    writeConcern: { w: "majority" }
  });

  // Both writes are atomic — either both commit or neither does
  await orders.insertOne({ _id: orderId, item: "widget", qty: 5 }, { session });
  await inventory.updateOne(
    { item: "widget" },
    { $inc: { qty: -5 } },
    { session }
  );

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

2. Replica Set Transactions

All primary-based writes in a replica set transaction are routed to the primary. The session object carries the transaction state.

Core API (explicit)

const { MongoClient } = require("mongodb");

async function transferFunds(client, fromAcct, toAcct, amount) {
  const session = client.startSession();

  try {
    session.startTransaction({
      readConcern: { level: "snapshot" },
      writeConcern: { w: "majority", j: true }   // note: field is "j", not "journal"
    });

    const accounts = client.db("bank").collection("accounts");

    const from = await accounts.findOne({ _id: fromAcct }, { session });
    if (!from || from.balance < amount) {
      throw new Error("Insufficient funds");
    }

    await accounts.updateOne(
      { _id: fromAcct },
      { $inc: { balance: -amount } },
      { session }
    );
    await accounts.updateOne(
      { _id: toAcct },
      { $inc: { balance: amount } },
      { session }
    );

    await session.commitTransaction();
    console.log("Transfer committed");
  } catch (err) {
    await session.abortTransaction();
    console.error("Transaction aborted:", err.message);
    throw err;
  } finally {
    await session.endSession();
  }
}

withTransaction() handles commit retry and transient error retry automatically. Prefer this over the manual try/catch pattern. Available since Node.js driver 3.2+ and PyMongo 3.9+.

async function transferWithHelper(client, fromAcct, toAcct, amount) {
  const session = client.startSession();
  try {
    // The driver passes the active session as the first argument to the callback.
    // Always use that parameter (not the outer `session` closure) so the callback
    // works correctly when withTransaction() retries it.
    await session.withTransaction(async (session) => {
      const accounts = client.db("bank").collection("accounts");

      const from = await accounts.findOne({ _id: fromAcct }, { session });
      if (!from || from.balance < amount) throw new Error("Insufficient funds");

      await accounts.updateOne(
        { _id: fromAcct }, { $inc: { balance: -amount } }, { session }
      );
      await accounts.updateOne(
        { _id: toAcct }, { $inc: { balance: amount } }, { session }
      );
    }, {
      readConcern: { level: "snapshot" },
      writeConcern: { w: "majority" }
    });
  } finally {
    await session.endSession();
  }
}

Key methods:

Method Purpose
session.startTransaction(options) Begin transaction with optional read/write concern
session.commitTransaction() Commit; returns a promise; retry on UnknownTransactionCommitResult
session.abortTransaction() Roll back all operations in the transaction
session.withTransaction(fn, options) Callback API; handles retry logic internally
session.endSession() Release the session back to the pool

3. Distributed Transactions on Sharded Clusters

Since MongoDB 4.2, multi-document transactions work across shards using two-phase commit (2PC).

Two-Phase Commit coordinator

When a transaction touches multiple shards, the mongos router designates one of the participant shards as the coordinator (the shard that receives the first write). The coordinator:

  1. Prepare phase — sends prepareTransaction to all participant shards; each shard locks its data and votes yes/no
  2. Commit phase — if all shards vote yes, coordinator sends commitTransaction to all; if any vote no, sends abortTransaction

The coordinator’s decision is durable in config.transactions so recovery is possible after coordinator failure.

// Sharded cluster transaction — same driver API; MongoDB routes internally
const session = client.startSession();
try {
  await session.withTransaction(async (session) => {
    // orders collection on shard A, inventory on shard B
    const orders = client.db("shop").collection("orders");
    const inventory = client.db("shop").collection("inventory");

    await orders.insertOne(
      { _id: new ObjectId(), customerId: "c1", sku: "sku-99", qty: 2 },
      { session }
    );
    await inventory.updateOne(
      { sku: "sku-99" },
      { $inc: { available: -2 } },
      { session }
    );
  }, { writeConcern: { w: "majority" } });
} finally {
  await session.endSession();
}

Performance cost vs. replica set transactions:


4. Read Concern in Transactions

The read concern set on startTransaction() applies to all reads within the transaction.

Read Concern Behavior in transactions
local Reads the latest data on the node; not guaranteed majority-committed, so reads can be rolled back on a failover — weakest durability guarantee of the three
majority Reads data acknowledged by a majority of replica set members; slower but consistent with majority writes
snapshot Default since 4.0 for transactions. Reads a consistent snapshot of data as of the transaction’s start time; provides the strongest isolation
// Explicit snapshot read concern (this is the default, shown for clarity)
session.startTransaction({
  readConcern: { level: "snapshot" },
  writeConcern: { w: "majority" }
});

// local read concern — avoids majority read overhead, weaker consistency guarantee
session.startTransaction({
  readConcern: { level: "local" },
  writeConcern: { w: "majority" }
});

Snapshot isolation detail:

// Checking for SnapshotTooOld in error handling
// SnapshotTooOld carries TransientTransactionError label — use withTransaction() or
// manually abort, end the old session, and start a fresh one before retrying.
if (err.codeName === "SnapshotTooOld") {
  await session.abortTransaction();
  await session.endSession();
  // Create a new session — the old snapshot is gone and cannot be reused
  const newSession = client.startSession();
  return retryTransaction(client, newSession);
}

5. Write Concern in Transactions

Write concern on a transaction applies at commit time — it controls how many replica set members must acknowledge the commit before the driver considers it successful.

// Recommended production write concern
session.startTransaction({
  readConcern: { level: "snapshot" },
  writeConcern: {
    w: "majority",    // majority of voting members must acknowledge
    j: true,          // commit must be written to journal (fsync)
    wtimeout: 5000    // abort if majority ack not received within 5 seconds
  }
});

Write concern levels:

w value Meaning
1 Only primary acknowledges — risk of data loss on primary failover
"majority" Majority of voting members acknowledge — recommended
Number > 1 Specific count of members must acknowledge

j: true (journaled):

wtimeout:

// Handle wtimeout — this is UnknownTransactionCommitResult territory
try {
  await session.commitTransaction();
} catch (err) {
  if (err.hasErrorLabel("UnknownTransactionCommitResult")) {
    // Commit may or may not have applied — retry commit only
    await session.commitTransaction(); // driver retries internally in withTransaction()
  } else {
    throw err;
  }
}

6. Transaction Limits

Operation count

Oplog entry size

Transaction lifetime

// mongosh — default is 60 seconds; raise for long-running batch transactions
db.adminCommand({ setParameter: 1, transactionLifetimeLimitSeconds: 120 })

WiredTiger cache pressure

// Check transaction limits in your workload (Node.js driver)
// db here is client.db("admin") or any db handle — serverStatus is a server-level command
const serverStatus = await client.db("admin").command({ serverStatus: 1 });
console.log(serverStatus.wiredTiger.cache["tracked dirty bytes in the cache"]);
console.log(serverStatus.transactions); // currentActive, totalCommitted, totalAborted

7. Retryable Transactions

MongoDB drivers classify transaction errors into two categories that require different retry strategies.

Error label taxonomy

Error label Meaning Action
TransientTransactionError Transaction aborted due to transient condition (write conflict, network blip, step-down) Abort and retry the entire transaction
UnknownTransactionCommitResult Commit result uncertain (network timeout, write concern timeout) Retry the commit only — do not re-run the transaction body

Manual retry pattern (Node.js)

// txnFunc signature: async (session) => void
// txnFunc is responsible for calling startTransaction() and all DB operations.
// runTransactionWithRetry handles abort-and-retry on TransientTransactionError.
async function runTransactionWithRetry(txnFunc, client) {
  const session = client.startSession();
  try {
    let attempts = 0;
    while (true) {
      try {
        attempts++;
        await txnFunc(session); // txnFunc must call session.startTransaction() internally
        break; // success — txnFunc called commitTransaction()
      } catch (err) {
        if (err.hasErrorLabel("TransientTransactionError") && attempts < 3) {
          console.log(`Transient error, retrying (attempt ${attempts})...`);
          await session.abortTransaction();
          continue; // restart: txnFunc will call startTransaction() again
        }
        throw err;
      }
    }
  } finally {
    await session.endSession();
  }
}

async function commitWithRetry(session) {
  let attempts = 0;
  while (true) {
    try {
      attempts++;
      await session.commitTransaction();
      break;
    } catch (err) {
      if (err.hasErrorLabel("UnknownTransactionCommitResult") && attempts < 3) {
        console.log(`Commit result unknown, retrying commit (attempt ${attempts})...`);
        continue;
      }
      throw err;
    }
  }
}

Automatic retry via withTransaction()

withTransaction() handles both TransientTransactionError (retries the callback) and UnknownTransactionCommitResult (retries commit) automatically. This is the recommended production pattern.

// withTransaction() — automatic retry for both error labels
const session = client.startSession();
try {
  await session.withTransaction(async (session) => {
    const orders = client.db("shop").collection("orders");
    const inventory = client.db("shop").collection("inventory");
    await orders.insertOne({ orderId: "o1", sku: "abc", qty: 1 }, { session });
    await inventory.updateOne({ sku: "abc" }, { $inc: { stock: -1 } }, { session });
  }, {
    readConcern: { level: "snapshot" },
    writeConcern: { w: "majority" }
  });
} finally {
  await session.endSession();
}

8. Driver Examples

Node.js (mongodb driver 5.x+)

const { MongoClient } = require("mongodb");

const client = new MongoClient(process.env.MONGO_URI);

async function placeOrder(customerId, sku, qty) {
  const session = client.startSession();
  try {
    await session.withTransaction(async (session) => {
      const db = client.db("shop");
      const inv = await db.collection("inventory").findOne({ sku }, { session });
      if (!inv || inv.stock < qty) throw new Error("Out of stock");

      await db.collection("orders").insertOne(
        { customerId, sku, qty, placedAt: new Date() },
        { session }
      );
      await db.collection("inventory").updateOne(
        { sku },
        { $inc: { stock: -qty } },
        { session }
      );
    }, {
      readConcern: { level: "snapshot" },
      writeConcern: { w: "majority" }
    });
    console.log("Order placed");
  } finally {
    await session.endSession();
  }
}

Python (PyMongo 4.x) — callback API

import os
from pymongo import MongoClient
from pymongo.read_concern import ReadConcern
from pymongo.write_concern import WriteConcern

client = MongoClient(os.environ["MONGO_URI"])

def place_order(customer_id, sku, qty):
    def txn_body(session):
        db = client["shop"]
        inv = db["inventory"].find_one({"sku": sku}, session=session)
        if not inv or inv["stock"] < qty:
            raise ValueError("Out of stock")
        db["orders"].insert_one(
            {"customer_id": customer_id, "sku": sku, "qty": qty},
            session=session
        )
        db["inventory"].update_one(
            {"sku": sku},
            {"$inc": {"stock": -qty}},
            session=session
        )

    with client.start_session() as session:
        session.with_transaction(
            txn_body,
            read_concern=ReadConcern("snapshot"),
            write_concern=WriteConcern(w="majority")
        )

Python (PyMongo 4.x) — core API (explicit)

from pymongo import MongoClient
from pymongo.errors import OperationFailure
from pymongo.read_concern import ReadConcern
from pymongo.write_concern import WriteConcern

def transfer_funds(client, from_id, to_id, amount):
    with client.start_session() as session:
        session.start_transaction(
            read_concern=ReadConcern("snapshot"),
            write_concern=WriteConcern(w="majority", j=True)
        )
        try:
            accts = client["bank"]["accounts"]
            src = accts.find_one({"_id": from_id}, session=session)
            if src["balance"] < amount:
                raise ValueError("Insufficient funds")
            accts.update_one({"_id": from_id}, {"$inc": {"balance": -amount}}, session=session)
            accts.update_one({"_id": to_id}, {"$inc": {"balance": amount}}, session=session)
            session.commit_transaction()
        except Exception:
            session.abort_transaction()
            raise

Java (MongoDB driver 4.x)

import com.mongodb.client.ClientSession;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.TransactionBody;
import com.mongodb.ReadConcern;
import com.mongodb.TransactionOptions;
import com.mongodb.WriteConcern;
import org.bson.Document;
import static com.mongodb.client.model.Filters.eq;
import static com.mongodb.client.model.Updates.inc;

try (ClientSession session = client.startSession()) {
    TransactionOptions txnOptions = TransactionOptions.builder()
        .readConcern(ReadConcern.SNAPSHOT)
        .writeConcern(WriteConcern.MAJORITY)
        .build();

    session.withTransaction((TransactionBody<Void>) () -> {
        MongoCollection<Document> orders = client
            .getDatabase("shop").getCollection("orders");
        MongoCollection<Document> inventory = client
            .getDatabase("shop").getCollection("inventory");

        Document inv = inventory.find(session, eq("sku", sku)).first();
        if (inv == null || inv.getInteger("stock") < qty) {
            throw new RuntimeException("Out of stock");
        }
        orders.insertOne(session, new Document("sku", sku).append("qty", qty));
        inventory.updateOne(session, eq("sku", sku), inc("stock", -qty));
        return null;
    }, txnOptions);
}

9. Performance Impact

Overhead measurement

Compared to a non-transactional equivalent write, a 2-operation replica set transaction adds:

// Benchmark helper — compare transactional vs non-transactional
async function benchmarkTxn(client, iterations) {
  const start = Date.now();
  for (let i = 0; i < iterations; i++) {
    const session = client.startSession();
    try {
      await session.withTransaction(async () => {
        await client.db("bench").collection("a")
          .updateOne({ _id: 1 }, { $inc: { n: 1 } }, { session });
        await client.db("bench").collection("b")
          .updateOne({ _id: 1 }, { $inc: { n: 1 } }, { session });
      }, { writeConcern: { w: "majority" } });
    } finally {
      await session.endSession();
    }
  }
  console.log(`${iterations} txns in ${Date.now() - start}ms`);
}

Write conflict storms

When many concurrent transactions attempt to modify the same document, MongoDB aborts all but the first writer, forcing retries. This is “hot document” contention.

// Detect write conflict storms in server status
const status = await db.admin().serverStatus();
// metrics.operation.writeConflicts — count of write-conflict retries at the storage layer
console.log("writeConflicts:", status.metrics.operation.writeConflicts);
// WiredTiger transaction conflict counter (equivalent low-level metric):
console.log("wtConflicts:", status.wiredTiger.transaction["transaction conflicts between concurrent transactions"]);

Mitigations:

When NOT to use transactions

Scenario Better alternative
Update a single document Atomic single-doc operations ($inc, $push, $set, findAndModify) — always atomic without a session
Append to an event log insertOne — single-doc inserts are already atomic
Read-then-write on a single document findOneAndUpdate with returnDocument: "after"
Idempotent upsert updateOne with upsert: true
Counter increment $inc — atomic, no transaction needed

10. Anti-Patterns

Anti-patterns summary table

Anti-pattern Problem Fix
Long-running transactions (> a few seconds) Hold WiredTiger cache, delay checkpoints, risk SnapshotTooOld abort Break into smaller transactions; denormalize to reduce operations per transaction
Using transactions for single-document operations 5–10× overhead vs. atomic single-doc ops; no correctness benefit Use $inc, $push, findOneAndUpdate, updateOne instead
Ignoring TransientTransactionError Transaction silently fails without data change; caller gets incorrect success Always check error labels; retry the full transaction body
Using w: 1 (not majority) on commit Risk of data loss if primary steps down before secondaries replicate Always commit with w: "majority" in production
Not calling endSession() Session leak; connection pool exhaustion after many failed transactions Use try/finally to always call session.endSession()
Re-using a session across unrelated logical operations State contamination; causal consistency applied where not intended Create a new session per logical transaction unit
Modifying the same hot document in concurrent transactions Write conflict storm; high abort/retry rate Redesign schema to distribute writes (bucketing, sharding, counters array)
Catching errors and not checking hasErrorLabel() Retrying commit when a TransientTransactionError requires full retry (or vice versa) Check err.hasErrorLabel("TransientTransactionError") vs "UnknownTransactionCommitResult"

Long-running transaction example (what NOT to do)

// ANTI-PATTERN: fetching external data inside a transaction
const session = client.startSession();
session.startTransaction();

const doc = await collection.findOne({ _id: id }, { session });

// BAD: this HTTP call takes 3 seconds — transaction holds snapshot the entire time
const enriched = await fetch(`https://api.example.com/enrich/${doc.key}`);

await collection.updateOne({ _id: id }, { $set: { extra: enriched.data } }, { session });
await session.commitTransaction(); // may fail with SnapshotTooOld
await session.endSession();
// CORRECT: fetch external data BEFORE opening the transaction
const doc = await collection.findOne({ _id: id }); // non-transactional read
const enriched = await fetch(`https://api.example.com/enrich/${doc.key}`); // outside txn

// Only the DB writes are inside the transaction — runs in < 10ms
const session = client.startSession();
try {
  await session.withTransaction(async (session) => {
    await collection.updateOne(
      { _id: id },
      { $set: { extra: enriched.data } },
      { session }
    );
  }, { writeConcern: { w: "majority" } });
} finally {
  await session.endSession();
}

References

  1. https://www.mongodb.com/docs/manual/core/transactions/
  2. https://www.mongodb.com/docs/manual/core/transactions-in-applications/
  3. https://www.mongodb.com/docs/manual/core/transactions-production-consideration/
  4. https://www.mongodb.com/docs/manual/reference/method/Session.startTransaction/
  5. https://www.mongodb.com/docs/drivers/node/current/fundamentals/transactions/
  6. https://www.mongodb.com/docs/manual/core/read-isolation-consistency-recency/