1. Single-Document Atomicity
Every write to a single document in MongoDB — including one that touches several nested fields or array elements inside it — is atomic by default, with no extra syntax required. Another reader either sees the document entirely before the write or entirely after it; there's no in-between state to observe.
db.accounts.updateOne(
{ _id: ObjectId("a1") },
{
$inc: { balance: -50 },
$push: { history: { type: "withdrawal", amount: 50, at: new Date() } }
}
)
That single call decrements balance and appends a history entry as
one indivisible operation — this is exactly the kind of update Week 3's embedding
patterns are designed to enable, and it's the reason a well-modeled document often
doesn't need a transaction at all. Where atomicity doesn't reach is across
separate documents: updating one account's balance and a second
account's balance as two separate calls leaves a window where only one of the two
has happened.
2. When You Actually Need a Transaction
A multi-document transaction is for the case single-document atomicity can't cover: a change that must apply to two or more documents together, often in two or more different collections, where a reader must never see the change to one applied without the other.
// account 1 loses money, account 2 gains it -- both must succeed, or neither should
db.accounts.updateOne({ _id: fromId }, { $inc: { balance: -amount } });
db.accounts.updateOne({ _id: toId }, { $inc: { balance: amount } });
Run as two independent calls, a crash between them leaves money that's left one account without ever arriving at the other. That's the specific failure mode a transaction closes: both updates commit together, or if anything fails, both roll back — the balances never end up in a partially-transferred state.
3. Sessions & the Transaction API
A transaction runs inside a client session, which groups a sequence of operations so the server can track and either commit or roll back all of them as a unit.
const session = client.startSession();
try {
session.startTransaction();
const accounts = client.db("bank").collection("accounts");
await accounts.updateOne(
{ _id: fromId, balance: { $gte: amount } }, // guard against overdraft in the same call
{ $inc: { balance: -amount } },
{ session }
);
await accounts.updateOne(
{ _id: toId },
{ $inc: { balance: amount } },
{ session }
);
await session.commitTransaction();
} catch (err) {
await session.abortTransaction();
throw err;
} finally {
await session.endSession();
}
Every operation that should be part of the transaction must explicitly pass
{ session } — an update that omits it runs entirely outside the
transaction, committed immediately regardless of what happens afterward. That's
the most common mistake when writing transaction code for the first time.
4. Handling Transient Errors
A transaction can fail for reasons outside your control — a replica set election
mid-transaction, a brief network blip — and the driver surfaces these as errors
labeled TransientTransactionError. The correct response isn't to
treat it as a hard failure; it's to retry the whole transaction from the start.
async function runTransactionWithRetry(session, fn) {
while (true) {
try {
session.startTransaction();
await fn(session);
await session.commitTransaction();
return;
} catch (err) {
await session.abortTransaction();
if (err.hasErrorLabel && err.hasErrorLabel("TransientTransactionError")) {
continue; // safe to retry the entire transaction from scratch
}
throw err;
}
}
}
This is a MongoDB-specific detail worth internalizing: the driver's error labels
(TransientTransactionError, UnknownTransactionCommitResult)
exist precisely so application code can distinguish "retry me" from "this
genuinely failed" without guessing from an error message.
5. Designing Transactions Away
Transactions carry real cost — extra round trips, held locks, more code to reason about — so the strongest tool in this module is often the one from Week 3: a schema that puts the data needing joint atomicity into one document in the first place.
// Two collections + a transaction to keep them in sync...
db.orders.updateOne({ _id: orderId }, { $set: { status: "shipped" } });
db.orderEvents.insertOne({ orderId, event: "shipped", at: new Date() });
// ...vs. one document, one atomic call, no transaction needed
db.orders.updateOne(
{ _id: orderId },
{
$set: { status: "shipped" },
$push: { events: { event: "shipped", at: new Date() } }
}
)
Not every case can be modeled this way — a funds transfer genuinely spans two
account documents that must each remain independently queryable — but it's worth
asking, before reaching for startTransaction(), whether the fields
that need to change together actually belong in the same document.
A common early mistake is wrapping every multi-step write in a transaction out of caution. Ask first whether the write is really touching independent documents that both need to exist as separate entities — if so, a transaction is the right tool; if the fields could reasonably live together, embedding removes the need for one entirely.
6. Hands-on Exercise
Build a retry-safe funds transfer function
A Node.js function that moves money between two accounts using a real MongoDB transaction, with correct error handling.
Requirements:
- Seed an
accountscollection with at least 3 documents, each with abalancefield. - Write a
transferFunds(fromId, toId, amount)function that opens a session, starts a transaction, and performs both the debit and credit updates with{ session }passed to each. - Add a guard in the debit query (
balance: { $gte: amount }) so an overdraft attempt matches zero documents instead of going negative, and check the update result to abort the transaction when that happens. - Wrap the transaction in the retry pattern from Section 4, retrying only on
TransientTransactionError. - Prove it works: run a successful transfer and confirm both balances updated together, then run an insufficient-funds transfer and confirm neither balance changed.
MongoDB transactions require a replica set (even a single-node one) — a standalone mongod won't support them. If startTransaction() fails immediately, check that your local server is running as a replica set, as covered in Week 7.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Does updating three nested fields inside one document with a single updateOne() call need a transaction?
Does updating three nested fields inside one document with a single updateOne() call need a transaction?
No — every write to a single document is already atomic in MongoDB, regardless of how many fields, nested objects, or array elements within it change. Transactions exist specifically for changes that must apply atomically across separate documents, not for multi-field updates within one document.
Q2
You start a transaction but forget to pass { session } to one of the update calls inside it. What happens?
You start a transaction but forget to pass { session } to one of the update calls inside it. What happens?
That update runs completely outside the transaction and commits immediately, whether or not the transaction it was meant to be part of later commits or aborts. This is a common source of subtle bugs — the fix is confirming every operation that should be part of a transaction explicitly passes the session.
Q3
Why should a TransientTransactionError trigger a full retry of the transaction, rather than being treated as a final failure?
Why should a TransientTransactionError trigger a full retry of the transaction, rather than being treated as a final failure?
That error label specifically means the failure was caused by a transient condition — a replica set election or brief network issue — not by anything wrong with the operations themselves. The driver labels it that way so application code knows it's safe, and expected, to retry the whole transaction from the start rather than surfacing it as a permanent error.
Q4
How does embedding a status history array inside an order document avoid the need for a transaction that two separate collections would require?
How does embedding a status history array inside an order document avoid the need for a transaction that two separate collections would require?
Updating the order's status field and pushing a new entry onto its embedded events array both happen in one document, in one call — which is atomic by default. If those two pieces lived in separate orders and orderEvents collections instead, keeping them in sync would require a multi-document transaction to guarantee a reader never sees one updated without the other.