Chapter 76 โ€” Transactions, Consistency & Concurrency

๐Ÿ“– Definitions

  • Atomicity in MongoDB โ€” every write to a single document is atomic, including its embedded arrays and sub-documents.
  • Multi-document transaction โ€” groups writes across documents/collections so they all commit or all roll back (replica sets since 4.0, sharded clusters since 4.2).
  • Write concern โ€” how many replica-set members must acknowledge a write before it counts as successful.
  • Read concern โ€” which data a read is allowed to see (e.g. only majority-committed data).
  • Read preference โ€” which member (primary or secondaries) a read is sent to.

๐Ÿ” Do You Even Need a Transaction?

Good document design avoids most transactions because related data lives in one document.

Situation Solution
Increment a counter, add to an array, update order status Single-document atomic update ($inc, $push, $set)
Decrement stock only if enough is left Conditional update: updateOne({ _id, stock: { $gte: qty } }, { $inc: { stock: -qty } })
Move money between two accounts Transaction (two documents must change together)
Create order + decrement stock for many products Transaction (or saga with compensation)

๐Ÿ’ป Code Example โ€” Transaction with the Node.js Driver

import { MongoClient } from "mongodb";

const client = new MongoClient(uri);
const accounts = client.db("bank").collection<{ _id: string; balance: number }>("accounts");
const ledger = client.db("bank").collection("ledger");

export async function transfer(from: string, to: string, amount: number) {
  const session = client.startSession();
  try {
    await session.withTransaction(
      async () => {
        const debit = await accounts.updateOne(
          { _id: from, balance: { $gte: amount } },      // guard: no negative balance
          { $inc: { balance: -amount } },
          { session },
        );
        if (debit.modifiedCount !== 1) throw new Error("Insufficient funds");

        await accounts.updateOne({ _id: to }, { $inc: { balance: amount } }, { session });
        await ledger.insertOne({ from, to, amount, at: new Date() }, { session });
      },
      {
        readConcern: { level: "snapshot" },
        writeConcern: { w: "majority" },
        readPreference: "primary",
      },
    );
  } finally {
    await session.endSession();
  }
}

Key points:

  • Every operation inside must pass { session } โ€” forgetting it runs the op outside the transaction.
  • withTransaction automatically retries on TransientTransactionError (e.g. write conflict) and retries the commit on UnknownTransactionCommitResult โ€” so the callback must be safe to run more than once (no external side effects like sending emails inside).
  • Throwing inside the callback aborts the transaction.

๐Ÿ’ป Code Example โ€” Mongoose

await mongoose.connection.transaction(async (session) => {
  const order = await Order.create([{ userId, items, total }], { session });   // create() needs an array with session
  for (const i of items) {
    const res = await Product.updateOne(
      { _id: i.productId, stock: { $gte: i.qty } },
      { $inc: { stock: -i.qty } },
      { session },
    );
    if (res.modifiedCount === 0) throw new AppError(409, `Out of stock: ${i.productId}`);
  }
  return order[0];
});

โš ๏ธ Transaction Limits & Costs

  • Requires a replica set or sharded cluster (a standalone mongod can't run them โ€” use a single-node replica set locally).
  • Default max runtime 60 seconds (transactionLifetimeLimitSeconds); keep them short.
  • Keep them small: modifying many thousands of documents in one transaction puts pressure on the WiredTiger cache.
  • Write conflicts: two transactions touching the same document โ†’ one gets WriteConflict and is retried.
  • Performance cost is real; "if you need transactions everywhere, reconsider your schema".
  • Collections and indexes can be created inside transactions since 4.4 (with restrictions).

๐Ÿ” Write Concern

db.orders.insertOne(doc, { writeConcern: { w: "majority", j: true, wtimeout: 5000 } });
Setting Meaning Trade-off
w: 0 Fire and forget, no ack Fastest, can lose data silently
w: 1 Primary acknowledged Can be rolled back if primary fails before replicating
w: "majority" Majority of voting members have it Survives failover โ€” default since MongoDB 5.0
j: true Written to the on-disk journal Durable across crash of that node
wtimeout Give up waiting after N ms Write may still succeed later

๐Ÿ” Read Concern

Level Returns Use for
local (default) Latest data on the node, may be rolled back Most reads
available Like local; on sharded clusters may return orphan docs Lowest latency
majority Data acknowledged by a majority โ€” never rolled back Financial reads, after failover safety
linearizable Reflects all majority writes completed before the read (primary only) Strongest single-doc reads; slow
snapshot Consistent point-in-time view Transactions, multi-query consistent reads

๐Ÿ” Read Preference

Mode Reads from
primary (default) Primary only โ€” always up to date
primaryPreferred Primary, secondary if primary unavailable
secondary Secondaries only โ€” may be stale (replication lag)
secondaryPreferred Secondaries, primary if none available
nearest Lowest network latency member

Reading from secondaries scales reads but gives eventual consistency: a user might not see their own write. Use it for analytics/reporting, not "show me what I just saved".

๐Ÿ’ป Causal Consistency ("Read Your Own Writes")

const session = client.startSession({ causalConsistency: true });
await users.updateOne({ _id }, { $set: { name: "New" } }, { session, writeConcern: { w: "majority" } });
// Even on a secondary, this read waits until it has seen the write above
const u = await users.findOne({ _id }, { session, readPreference: "secondary", readConcern: { level: "majority" } });

๐Ÿ’ป Optimistic Concurrency Control (Lost-Update Prevention)

Problem: two admins open the same product, both edit, last save silently overwrites the first.

// Each document carries a version number
const product = await products.findOne({ _id });                       // { โ€ฆ, version: 7 }

const res = await products.updateOne(
  { _id, version: product.version },                                  // only if nobody changed it
  { $set: { price: newPrice }, $inc: { version: 1 } },
);
if (res.matchedCount === 0) throw new AppError(409, "Product was modified by someone else โ€” reload");

Mongoose has this built in: new Schema({...}, { optimisticConcurrency: true }) uses __v and throws VersionError on save() conflicts.

Pessimistic locking (lock first) is rarely used in MongoDB; you can emulate it with a lockedBy/lockedUntil field set via findOneAndUpdate.

๐Ÿ’ป Idempotency for Retries

// Client sends an Idempotency-Key header; a unique index makes duplicates impossible
db.payments.createIndex({ idempotencyKey: 1 }, { unique: true });

try {
  await payments.insertOne({ idempotencyKey: key, amount, userId, status: "pending" });
} catch (e) {
  if ((e as { code?: number }).code === 11000) return payments.findOne({ idempotencyKey: key }); // retry โ†’ same result
  throw e;
}

MongoDB drivers also enable retryable writes by default (retryWrites=true) โ€” a single write is retried once after a network error or failover without being applied twice.

๐Ÿ” Isolation & Locking (Under the Hood)

  • WiredTiger uses document-level concurrency control (MVCC) โ€” writers to different documents don't block each other.
  • Outside transactions, a multi-document updateMany is not atomic as a whole; other readers can see it half-applied.
  • Inside a transaction, reads see a snapshot; writes are invisible to others until commit.

๐Ÿ“Š Is MongoDB ACID?

Property MongoDB
Atomicity Single doc always; multi-doc via transactions
Consistency Schema validation, unique indexes, transactions
Isolation Snapshot isolation in transactions
Durability Journaling + w: "majority"

So yes โ€” MongoDB is ACID compliant for multi-document transactions since 4.0.

๐ŸŒ Real-World Impact

  • Conditional $inc on stock prevents overselling without any transaction.
  • w: "majority" prevents "order disappeared after failover" incidents.
  • Optimistic concurrency eliminates silent overwrites in admin panels.

๐ŸŽฏ Likely Interview Questions

  1. Are single-document writes atomic? โ€” Yes, always.
  2. When do you need a multi-document transaction? โ€” Several documents must change together (transfers, order + stock across docs).
  3. What are the requirements and limits of transactions? โ€” Replica set/sharded cluster; 60s default limit; pass the session to every op; keep them small.
  4. Why does withTransaction require idempotent callbacks? โ€” It retries on transient errors and unknown commit results.
  5. Explain write concern w: 1 vs majority. โ€” Primary-only ack (can be rolled back on failover) vs majority ack (durable across failover).
  6. What is read concern majority? โ€” Read only data that can't be rolled back.
  7. What are the risks of reading from secondaries? โ€” Stale data due to replication lag; no read-your-own-writes unless causal sessions.
  8. How do you prevent lost updates? โ€” Optimistic concurrency with a version field in the filter.
  9. How do you prevent overselling? โ€” Conditional atomic update { stock: { $gte: qty } } + $inc.
  10. What are retryable writes? โ€” Drivers automatically retry a write once on transient network errors without double-applying.
  11. Is MongoDB ACID? โ€” Yes for single docs always, for multi-document transactions since 4.0/4.2.
  12. What happens on a write conflict in a transaction? โ€” One transaction aborts with a transient error and is retried.

โ† populate vs $lookup | Index | Next: Performance Tuning โ†’