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.
  • endSession in finally — 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

The bank transfer inside withTransaction: debit, credit and record commit together; a failed guard aborts and a transient error retries

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.
  • withTransaction auto-retries on transient errors.
  • All three writes commit together or none does.
  • The application throws insufficient funds if 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

Note: this post is in progress. Planned additions: deadlock-style contention examples, comparison with Postgres BEGIN; ... COMMIT, choreography vs orchestration sagas walkthrough.