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