Transactions in MongoDB

MongoDB Intermediate/Advanced — Transactions in MongoDB
MongoDB Intermediate/Advanced
Chapter 4 · Transactions in MongoDB

🔒 Transactions in MongoDB

Chapter 3 ended with a signal for when embedding breaks down and referencing takes over. Referencing has a cost beyond needing $lookup to join data back together: once related data lives in separate documents, a single logical operation can span more than one of them — and MongoDB needs a way to make sure they change together, or not at all. That's what transactions are for.

Why Transactions? The Multi-Document Problem

A write to a single document in MongoDB is already atomic by default — an updateOne() either fully applies or doesn't happen at all, with no built-in transaction needed. That's part of why embedding (Chapter 3, mongodb1-5) is so attractive: an embedded post-with-comments update touches exactly one document, so it's automatically all-or-nothing.

The problem shows up once an operation legitimately needs to change multiple documents — possibly in different collections — as one unit. Chapter 3's comment-count refactor is a direct example: after splitting comments into their own collection, "add a comment" now means two separate writes — inserting the new comment document, and incrementing the post's precomputed comment_count field. Without a transaction, a crash between those two writes leaves the comment saved but the count wrong — the two documents have drifted out of sync.

ACID, Recap

The same four guarantees SQL transactions provide (mysql_foundations_10) apply here: Atomicity (all writes in the transaction succeed, or none do), Consistency (the database moves from one valid state to another), Isolation (concurrent transactions don't see each other's half-finished work), and Durability (once committed, it survives a crash). MongoDB's multi-document transactions genuinely provide all four — this isn't a partial or "best effort" version of ACID.

The Session-Based API

A transaction is tied to a session — every operation that should be part of the transaction has to explicitly run inside that session. The three core steps: startSession(), startTransaction(), then either commitTransaction() on success or abortTransaction() on failure.

const session = client.startSession(); try { session.startTransaction(); await accounts.updateOne( { _id: "A1" }, { $inc: { balance: -100 } }, { session } ); await accounts.updateOne( { _id: "A2" }, { $inc: { balance: 100 } }, { session } ); await session.commitTransaction(); } catch (error) { await session.abortTransaction(); throw error; } finally { session.endSession(); }

Every operation that should be part of the transaction must pass { session } explicitly — an operation that forgets it runs entirely outside the transaction, unprotected by any of its guarantees. If either update throws (say, account A1 doesn't have enough balance and application logic rejects it), abortTransaction() rolls back both writes — the debit never happened, either, even though it ran first and technically "succeeded" on its own.

withTransaction(): The Safer Shorthand

The driver also provides withTransaction(), which wraps the try/commit/catch/abort boilerplate above and automatically retries the whole callback on certain transient errors (see this chapter's closing gotcha) — the recommended approach for production code:

const session = client.startSession(); try { await session.withTransaction(async () => { await accounts.updateOne({ _id: "A1" }, { $inc: { balance: -100 } }, { session }); await accounts.updateOne({ _id: "A2" }, { $inc: { balance: 100 } }, { session }); }); } finally { session.endSession(); }

Chapter 3's Comment Refactor, Without vs. With a Transaction

Without a Transaction

Insert the comment, then increment comment_count, as two independent writes. A crash (or thrown error) between them leaves the comment saved but the count under-reporting it — permanently, until something notices and fixes it.

With a Transaction

Both writes run inside one withTransaction() block. Either the comment is inserted and the count is incremented, or neither happens — the two documents can never be observed out of sync with each other.

When You Actually Need Transactions

Skip Transactions When

The operation only touches one document — embedding (Chapter 3) already made this the common case on purpose, and a single-document write is atomic for free, with no transaction overhead at all.

Use Transactions When

An operation must change multiple documents — possibly across collections — and it would be genuinely wrong for some of those writes to succeed while others fail: a funds transfer, an order that must decrement inventory as it's created, a referenced comment-count update.

MethodPurpose
startSession()Creates a session — the container a transaction is scoped to
startTransaction()Begins a transaction on that session
commitTransaction()Applies every write made in the transaction, atomically
abortTransaction()Rolls back every write made in the transaction so far
withTransaction(fn)Runs fn inside a transaction, auto-retrying on transient errors, auto-committing/aborting

💻 Coding Challenges

Challenge 1: Identify the Multi-Document Risk

An e-commerce checkout needs to (a) insert a new order document and (b) decrement the ordered product's stock field in a separate products collection. Explain, in terms of this chapter's ACID recap, what could go wrong without a transaction.

Goal: Practice articulating the specific failure mode (partial, inconsistent writes) a transaction is meant to prevent, not just naming "atomicity" abstractly.

→ Solution

Challenge 2: Write the Checkout Transaction

Using this chapter's withTransaction() pattern, write the session-based code for Challenge 1's checkout: insert the order, then decrement the product's stock, both inside one transaction.

Goal: Practice the full session lifecycle — startSession(), passing { session } to every operation, and endSession() in a finally block.

→ Solution

Challenge 3: Transaction or Not?

Decide whether each of these needs a transaction, and justify why: (a) updating a single user's last_login timestamp, (b) moving a task between two different users' embedded tasks arrays inside their own user documents, (c) transferring loyalty points between two separate customer_accounts documents.

Goal: Practice applying the "single document vs. multiple documents" test directly, including the trickier case (b) where two different top-level documents are each individually atomic on their own.

→ Solution

⚠️ Gotcha: Transactions Require a Replica Set, and Aren't Free

Multi-document transactions only work against a replica set (or sharded cluster) — a standalone single-node MongoDB instance cannot run them at all, which surprises developers testing locally against a bare mongod. Transactions also carry real performance overhead compared to independent single-document writes, and can hit transient errors (network blips, transaction conflicts) that are expected to be retried, not treated as hard failures — which is exactly why withTransaction(), with its built-in retry logic, is the recommended API over hand-rolling startTransaction/commitTransaction yourself. Keep transactions short and touching as few documents as reasonably possible — they are the exception for cross-document atomicity, not a default way to write MongoDB code.

🎯 What's Next

The next chapter is Replication — the replica sets that transactions turn out to require, how MongoDB elects a primary, automatic failover, and how read preference lets an application choose where its reads come from.