MongoDB Course — 09 · Multi-Document Transactions
MongoDB has had multi-document ACID transactions since 4.0 (replica sets) and 4.2 (sharded clusters). They look like SQL transactions (begin / commit / abort), behave like SQL transactions (snapshot isolation), and carry SQL-like costs (locking, latency, contention). This post covers how to use them safely — sessions, retry on transient errors, abort vs commit, the limits — and the more important question: when NOT to reach for a transaction (in MongoDB, the answer is "more often than you think").
Series map: 00 Foundations → … → 08 Connection Pool & Concurrency → 09 Transactions (this post) → 10 Replica Sets → … → 17 Drivers & Case Studies.
Learning Outcome
- Start, commit, and abort a multi-document transaction.
- Handle transient transaction errors with a retry loop.
- Understand snapshot isolation + transaction limits (60 s, 16 MB).
- Recognise when a transaction is the right tool — and when single-doc atomicity or schema redesign is better.
Chapter 1 — Why transactions in MongoDB
Single-document updates are already atomic in MongoDB — that's true for any update operator on one document, no transaction needed.
Transactions matter when you need atomicity across multiple documents (possibly across multiple collections, possibly across multiple shards):
- Transfer money between two accounts.
- Decrement stock + insert an order line.
- Update a parent + add to its children collection consistently.
Before 4.0, the workaround was the two-phase commit pattern in application code — verbose and error-prone. Transactions replaced that for most use cases.
Chapter 2 — Anatomy of a transaction
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const session = client.startSession();
try {
await session.withTransaction(async () => {
const accounts = db.collection('accounts');
await accounts.updateOne(
{ _id: 'A', balance: { $gte: 100 } },
{ $inc: { balance: -100 } },
{ session }
);
await accounts.updateOne(
{ _id: 'B' },
{ $inc: { balance: 100 } },
{ session }
);
});
} finally {
await session.endSession();
}
Key points:
- Session — a transaction lives inside a session.
client.startSession(). withTransaction(cb)— driver helper that begins, runs the callback, commits, and retries on transient errors automatically.{ session }option — must be passed to every operation that's part of the transaction. Forgetting it silently runs that op outside the transaction.- Either commits all writes or none — snapshot isolation throughout.
endSessioninfinally— always.
Manual lifecycle (without withTransaction)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
session.startTransaction({
readConcern: { level: 'snapshot' },
writeConcern: { w: 'majority' },
readPreference: 'primary',
});
try {
await coll.insertOne(doc, { session });
await coll.updateOne(filter, update, { session });
await session.commitTransaction();
} catch (e) {
await session.abortTransaction();
throw e;
} finally {
await session.endSession();
}
withTransaction wraps this pattern + retry logic. Prefer it.
Chapter 3 — Read concern + write concern inside transactions
Transactions default to:
readConcern: 'snapshot'— reads see a consistent point-in-time view; no torn reads.writeConcern: 'majority'— required for transactions on replica sets.readPreference: 'primary'— required.
You can override per transaction. Stricter concerns are slower; the defaults are correct for most cases.
Chapter 4 — Transient errors + retry
A transaction may fail with a transient transaction error for reasons unrelated to your code:
- Primary failover mid-transaction.
- Lock conflict with another transaction.
- Network blip.
withTransaction retries automatically when the error is marked TransientTransactionError or UnknownTransactionCommitResult. Most apps need no extra logic.
Manual retry pattern (for non-helper code):
1
2
3
4
5
6
7
8
9
10
11
12
13
async function runWithRetry(txnFn) {
while (true) {
try {
return await txnFn();
} catch (e) {
if (e.hasErrorLabel?.('TransientTransactionError')) {
continue; // retry
}
throw e;
}
}
}
Always add a max-retry guard in production to avoid infinite loops on persistent failure.
Chapter 5 — Limits and gotchas
| Limit | Default |
|---|---|
| Transaction lifetime | 60 seconds (server transactionLifetimeLimitSeconds) |
| Total oplog size per transaction | 16 MB |
| Operations per transaction | unlimited in count, capped by oplog size |
| Read preference | primary only (replica set) |
| Cross-shard transactions | supported since 4.2; slower than single-shard |
| DDL inside transaction | limited (no createCollection in some versions; check current docs) |
| Capped collections | can't be written inside a transaction |
Watch out: the 60-second default kills long-running transactions. For bulk operations, chunk into many small transactions rather than one huge one.
Performance impact
A transaction holds locks for its duration. Other writers to the same documents queue or conflict. The longer the transaction, the worse the contention.
Rule of thumb:
- Sub-100 ms transactions are usually fine.
- 100 ms-1 s transactions add measurable lag at scale.
- Multi-second transactions almost always indicate a design problem.
Chapter 6 — Read-then-write inside transactions
1
2
3
4
5
6
7
8
9
10
11
12
13
await session.withTransaction(async () => {
const inv = await items.findOne({ _id: itemId }, { session });
if (inv.stock < qty) {
throw new Error('out of stock');
}
await items.updateOne(
{ _id: itemId, stock: { $gte: qty } }, // optimistic guard
{ $inc: { stock: -qty } },
{ session }
);
await orders.insertOne({ ... }, { session });
});
The double safeguard (in-app check + filter condition) handles the race where another transaction modifies stock between your read and your write.
Without a transaction, you could still do this with optimistic check on the filter — the transaction adds atomicity across the insert and the decrement plus a snapshot view.
Chapter 7 — When NOT to use transactions
Transactions are powerful + tempting, but expensive. Cheaper alternatives:
Single-document atomicity (free)
1
2
3
4
5
db.accounts.updateOne(
{ _id: 'A', balance: { $gte: 100 } },
{ $inc: { balance: -100 } }
)
The whole update is atomic. If the filter doesn't match (balance < 100), nothing changes. Most "decrement counter, reject if would go negative" problems collapse to this one statement.
Embed instead of reference
If you find yourself in a transaction across parent + parent_children, ask whether the children should be embedded in the parent (Post 07). A single update on an embedded array is atomic, no transaction needed.
Idempotent retries
If the operation is idempotent, just retry on failure. No transaction needed — eventual consistency settles.
1
2
3
4
5
6
7
// idempotent upsert with a unique key
await db.collection('events').updateOne(
{ event_id: id },
{ $setOnInsert: { ... } },
{ upsert: true }
)
Choreography (sagas) for distributed flows
For long-running cross-service flows (order → payment → shipping), use a saga / compensating action pattern. Transactions don't help across systems.
Compute + materialise
Materialised views via $merge (Post 06) or change-stream-driven sync (Post 12) replace many "update two collections at once" cases.
Trick: before reaching for a transaction, ask: "Can a single-document update with a filter condition serve this?" Answer is yes 70-80 % of the time.
Chapter 8 — Sharded transactions
Cross-shard transactions work since 4.2. They use a two-phase commit under the hood, so they're slower than single-shard.
Performance tips:
- Design schema so transactions hit one shard whenever possible.
- Choose the shard key carefully — a transaction touching docs on the same shard key value stays single-shard.
- Distributed transactions across many shards multiply latency by the number of shards.
If your design requires frequent cross-shard transactions, the schema or shard-key choice may be wrong.
Chapter 9 — Pattern: bank transfer with retry
A full production-grade money-transfer endpoint:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
async function transfer(fromId, toId, amount) {
if (amount <= 0) throw new Error('amount must be positive');
const session = client.startSession();
try {
await session.withTransaction(async () => {
const accounts = db.collection('accounts');
const debit = await accounts.updateOne(
{ _id: fromId, balance: { $gte: amount } },
{ $inc: { balance: -amount } },
{ session }
);
if (debit.matchedCount === 0) {
throw new Error('insufficient funds');
}
await accounts.updateOne(
{ _id: toId },
{ $inc: { balance: amount } },
{ session }
);
await db.collection('transfers').insertOne(
{ from: fromId, to: toId, amount, at: new Date() },
{ session }
);
}, {
readConcern: { level: 'snapshot' },
writeConcern: { w: 'majority' },
});
} finally {
await session.endSession();
}
}
Notes:
- The optimistic guard (
balance: { $gte: amount }) prevents the double-spend race. withTransactionauto-retries on transient errors.- All three writes commit together or none does.
- The application throws
insufficient fundsif the debit didn't match — that exception aborts the transaction.
Chapter 10 — Common pitfalls
| Pitfall | Fix |
|---|---|
Forgot to pass { session } to one op |
always include — that op runs outside the transaction |
| Transaction lifetime exceeded | shorter transactions or smaller chunks |
| Locking contention slowing whole cluster | move to schema-based atomicity; shorter transactions |
| Used transaction where single-doc atomicity sufficed | remove the transaction |
| Manual lifecycle without retry on transient | use withTransaction helper |
| Sharded transaction touches many shards | shard-key alignment; or redesign |
| Capped collection inside transaction | not supported; redesign |
Read concern not snapshot |
check it; otherwise inconsistent reads inside the txn |
| Long-running session held open | endSession in finally always |
Practice Exercises
Exercise 1 — First transaction
Create an accounts collection with two docs (balances 1000 + 0). Run a transfer transaction moving 100. Verify both balances.
Exercise 2 — Forced abort
In the transaction body, after the debit but before the credit, throw new Error('boom'). Verify the debit was rolled back (balance still 1000).
Exercise 3 — Race test
In parallel, fire 10 transfers of 200 each from A (balance 1000). Without a transaction the result might be -1000. With your transaction + optimistic filter, exactly 5 succeed and 5 throw "insufficient funds".
Exercise 4 — Single-doc replacement
Refactor: do you really need a transaction for "decrement stock + abort if would go below 0"? Show the single-statement version.
Solution
1
2
3
4
5
6
db.products.updateOne(
{ _id: pid, stock: { $gte: qty } },
{ $inc: { stock: -qty } }
)
// matchedCount === 0 → was out of stock
Exercise 5 — Lifetime limit
Inside a transaction, write 10k docs in a loop with no batching. Confirm it eventually fails when the 60s lifetime fires.
Exercise 6 — Retry harness
Write your own retry-on-transient wrapper with a max-attempts cap of 3 and exponential backoff. Force a transient by aborting the transaction once during your callback.
Cheat-sheet
| What | How |
|---|---|
| Start session | const session = client.startSession() |
| Run transaction (helper) | await session.withTransaction(async () => { ... }) |
| Pass session | { session } to every op inside the transaction |
| Default concerns | snapshot read, majority write, primary read pref |
| Lifetime cap | 60 s; configure transactionLifetimeLimitSeconds cautiously |
| Oplog cap | 16 MB per txn |
| Transient error | withTransaction retries automatically |
| Snapshot isolation | reads see consistent point-in-time view |
| Single-doc atomic | already atomic; no transaction needed |
| Ask first | can single-doc + filter handle this? if yes, do that |
| Schema review | embed children → fewer transactions |
| Cross-shard | works (4.2+) but slower; align shard keys |
| Capped collection write | NOT supported in transaction |
| End session | finally { await session.endSession() } |
What's next
Post 10 covers replica sets — primary / secondary / arbiter, the election protocol, oplog, secondary read staleness, write concern interaction with replication, and the automatic failover that makes MongoDB highly available.
References
- Transactions
withTransaction- Production Considerations for Transactions
- Sharded Cluster Transactions
- "Single Document Atomicity" — see Atomicity and Transactions.
Note: this post is in progress. Planned additions: deadlock-style contention examples, comparison with Postgres
BEGIN; ... COMMIT, choreography vs orchestration sagas walkthrough.










