← back

Multi-Document
Transactions

FILE  32_transactions
TOPIC  ACID · Sessions · withTransaction · Write Concern · Read Concern · Retry Logic
LEVEL  Intermediate/Advanced
01
Transaction Basics
Multi-document ACID transactions — added in MongoDB 4.0
concept

MongoDB has always had single-document atomicity — any write to a single document is atomic regardless of how many fields or embedded arrays change. Multi-document ACID transactions were added in v4.0 for replica sets and v4.2 for sharded clusters.

NOTE
MongoDB's recommended design philosophy is to model data to avoid transactions through embedding. Transactions are needed when business logic genuinely requires atomic updates across multiple documents that cannot be re-modeled into a single document.

When Transactions Are Needed

  • Bank transfer: debit account A AND credit account B — both must succeed or neither
  • Create order AND decrement inventory in one atomic operation
  • Update multiple collections that must stay consistent (order + cart + reservation)

When Transactions Are NOT Needed

  • Updating a single document (always atomic — no transaction overhead)
  • Data co-located in one document (order + embedded line items)
  • Read-only operations
02
ACID in MongoDB
How each property is enforced
ACID
PropertyGuaranteeMechanism
AtomicityAll operations commit or all roll backWiredTiger transaction log; automatic rollback on abort
ConsistencyDB moves from one valid state to anotherSchema validation + constraints checked at commit
IsolationTransactions don't see each other's uncommitted writesSnapshot isolation (read concern snapshot default)
DurabilityCommitted writes survive crashesWiredTiger journal flushed; write concern majority
// Single-document write — ALWAYS atomic, no transaction needed
db.orders.updateOne(
  { _id: orderId },
  {
    $set:  { status: "confirmed" },
    $push: { lineItems: newItem },
    $inc:  { total: newItem.price }
  }
)
// All three changes guaranteed atomic — one of MongoDB's core guarantees

// Multi-document — transaction required for atomicity
// Without transaction: crash between step 1 and 2 → inconsistent state
db.accounts.updateOne({ _id: "A" }, { $inc: { balance: -100 } })  // step 1
db.accounts.updateOne({ _id: "B" }, { $inc: { balance: +100 } })  // step 2 — not atomic!
03
Sessions & Transaction Syntax
startSession · startTransaction · commit · abort
syntax

Transactions require a client session. Every operation inside the transaction must pass the same session object.

// Low-level API (Node.js driver)
const session = client.startSession()
try {
  session.startTransaction({
    readConcern:     { level: "snapshot" },
    writeConcern:    { w: "majority" },
    maxCommitTimeMS: 5000
  })

  await db.collection("accounts").updateOne(
    { _id: "A" },
    { $inc: { balance: -amount } },
    { session }           // ← session required on every operation
  )
  await db.collection("accounts").updateOne(
    { _id: "B" },
    { $inc: { balance: +amount } },
    { session }
  )

  await session.commitTransaction()
} catch (err) {
  await session.abortTransaction()
  throw err
} finally {
  await session.endSession()   // always release session
}
WARN
Operations inside a transaction that omit the { session } option run outside the transaction — not covered by its atomicity. This silent mistake causes partial consistency. Always pass { session } to every operation inside a transaction.
04
withTransaction Helper
Recommended API — automatic retry loop for transient errors
helper
// ✅ Recommended: withTransaction handles retry automatically
const session = client.startSession()
try {
  await session.withTransaction(async () => {
    const accts = db.collection("accounts")

    const sender = await accts.findOne({ _id: "A" }, { session })
    if (sender.balance < amount) throw new Error("Insufficient funds")

    await accts.updateOne({ _id: "A" }, { $inc: { balance: -amount } }, { session })
    await accts.updateOne({ _id: "B" }, { $inc: { balance: +amount } }, { session })
    // Callback resolving → auto-commit
    // Callback throwing → auto-abort
    // TransientTransactionError → auto-retry entire callback
    // UnknownTransactionCommitResult → auto-retry commit only
  })
} finally {
  await session.endSession()
}

Retry Error Labels

LabelMeaningwithTransaction Action
TransientTransactionErrorTransient error — safe to retryRetries entire callback
UnknownTransactionCommitResultCommit outcome unknown (network)Retries commit only
OtherApplication errorAborts and throws
TIP
Because withTransaction() may retry the callback, make it idempotent. Do not send emails, call external APIs, or produce other side effects inside the callback — do those only after withTransaction() resolves successfully.
05
Write & Read Concern
Durability and isolation control
concerns

Write Concern

Write ConcernMeaningDurability
{ w: 1 }Primary acknowledges onlyData lost if primary fails before replication
{ w: "majority" }Majority of replica set members acknowledge (default in transactions)Survives primary failure
{ w: 0 }Fire-and-forgetNo guarantee (logging/analytics only)
{ j: true }Journal flushed to diskSurvives server restart

Read Concern

Read ConcernMeaningUse Case
localMost recent data on this nodeDefault non-transaction reads; slight stale risk
majorityMajority-committed data onlyCausal consistency; no stale reads
snapshotPoint-in-time consistent snapshotDefault in transactions; no phantom reads
linearizableReflects all prior majority-committed writesStrongest guarantee; highest latency
06
Error Handling & Retry
Common transaction errors and resolution
errors
Error CodeMeaningAction
112 WriteConflictTwo txns modified same document concurrentlyRetry whole transaction (TransientTransactionError)
251 NoSuchTransactionTransaction expired (60s default)Retry whole transaction
256 TransactionExceededLifetime60s limit hitAbort; redesign to use smaller transactions
11600 InterruptedAtShutdownmongod shutdown mid-transactionRetry after restart
// Manual retry loop pattern (when not using withTransaction)
async function runWithRetry(txnFn, client) {
  while (true) {
    const session = client.startSession()
    try {
      session.startTransaction()
      await txnFn(session)
      await commitWithRetry(session)
      return
    } catch (err) {
      await session.abortTransaction()
      if (err.hasErrorLabel("TransientTransactionError")) continue
      throw err
    } finally { await session.endSession() }
  }
}
async function commitWithRetry(session) {
  while (true) {
    try { await session.commitTransaction(); return }
    catch (err) {
      if (err.hasErrorLabel("UnknownTransactionCommitResult")) continue
      throw err
    }
  }
}
07
Performance Implications
Transactions are powerful but expensive — design to minimize use
performance

Transaction Overhead

  • Lock contention: touched documents locked until commit/abort — concurrent writes on same document blocked
  • Extra network round-trips: startTransaction → ops → commitTransaction → majority ack
  • Snapshot overhead: WiredTiger maintains multiple versions of modified documents during transaction lifetime
  • Large oplog entry: entire transaction written atomically to oplog at commit

Hard Limits

LimitDefaultConfig
Transaction lifetime60 secondstransactionLifetimeLimitSeconds
DDL inside transactionNot allowed (pre-5.0)Limited in 5.0+
Sharded: collection must existCannot create inside txnPre-create collections
DANGER
Long-running transactions hold locks, consume snapshot memory, and cause replication lag. If a transaction needs >100 document touches or approaches the 60s limit, rethink the schema — embedding is almost always the right answer to avoid transactions entirely.
// ✅ Prefer atomic single-doc pattern over transactions
// Reserve inventory atomically; only create order if reservation succeeds
const reserved = await db.inventory.findOneAndUpdate(
  { sku: sku, qty: { $gte: 1 } },
  { $inc: { qty: -1 } },
  { returnDocument: "after" }
)
if (!reserved) throw new Error("Out of stock")
await db.orders.insertOne(newOrder)  // inventory already atomically decremented