// LOADING
// LOADING
// LOADING_ARTICLE
MongoDB has supported multi-document ACID transactions since version 4.0, but a common mistake is reaching for them by default when simpler patterns suffice. Transactions solve a specific class of problem — and they cost something. Understanding the trade-off is what separates production-grade code from code that barely passes tests.
For a single document, MongoDB already provides all four guarantees without transactions — writes to a single document are atomic by definition. Transactions are necessary when atomicity must span multiple documents or collections.
The classic case: transferring funds between two accounts.
jsconst session = await mongoose.startSession() try { await session.withTransaction(async () => { const from = await Account.findById(fromId).session(session) const to = await Account.findById(toId).session(session) if (from.balance < amount) { throw new Error('Insufficient funds') } await Account.updateOne( { _id: fromId }, { $inc: { balance: -amount } }, { session } ) await Account.updateOne( { _id: toId }, { $inc: { balance: amount } }, { session } ) }) } finally { await session.endSession() }
Without the transaction, a crash between the two updateOne calls leaves the accounts in an inconsistent state: money removed from one account but never credited to the other.
Other scenarios where transactions are justified:
MongoDB transactions require a replica set (or sharded cluster). They use more resources than non-transactional writes:
In serverless environments, transactions pair poorly with cold starts and connection overhead — see the connection pooling guide for why this matters.
Single-document updates are already atomic. If you can restructure your data to capture related state in one document — for example, embedding order line items inside the order document — you eliminate the need for a transaction entirely. This is one of the strongest arguments for embedding over referencing.
Idempotent writes with no consistency requirement: If two collections can tolerate brief inconsistency (for example, a cache document and the source-of-truth document), or if the write can be safely retried without harm, a transaction adds cost without value. The idempotent API design guide covers patterns for making operations safely retryable.
Event sourcing or outbox patterns: Instead of writing to two collections atomically, write one event document and have a background process apply it. This trades transaction complexity for eventual consistency and is often more scalable.
When two transactions modify the same document simultaneously, one will receive a write conflict error (WriteConflict, error code 112). The correct response is to retry:
jsconst MAX_RETRIES = 3 async function transferWithRetry(fromId, toId, amount) { const session = await mongoose.startSession() let attempt = 0 try { while (attempt < MAX_RETRIES) { try { await session.withTransaction(async () => { // ... transaction body }) return // success } catch (err) { if (err.code === 112 && attempt < MAX_RETRIES - 1) { attempt++ continue // retry } throw err // non-retryable or exhausted retries } } } finally { await session.endSession() } }
session.withTransaction() in the Mongoose/Node.js driver already handles retries for transient errors automatically in many cases — read the driver documentation for the version you are using before writing your own retry loop.
By default, reads inside a transaction use snapshot read concern: you see a consistent point-in-time snapshot of the data as it existed when the transaction started. This prevents dirty reads and non-repeatable reads.
js// Explicitly set read concern for clarity await session.withTransaction( async () => { /* ... */ }, { readConcern: { level: 'snapshot' }, writeConcern: { w: 'majority' } } )
Using w: 'majority' for write concern ensures the transaction is durable even if a primary failover occurs immediately after commit.
Aggregation pipelines can run inside a session and respect the transaction's snapshot isolation:
jsconst summary = await Order.aggregate( [{ $match: { userId } }, { $group: { _id: null, total: { $sum: '$amount' } } }], { session } )
This is useful when you need to read a derived value (like a running balance) and then make a conditional write in the same atomic operation. See the aggregation pipeline guide for stage details.
WriteConflict with retries.For the broader backend architecture that hosts these operations, the project structure guide shows how to isolate data-access logic so that transactional boundaries are clear and not scattered across route handlers. Browse the interview prep section for common database transaction questions asked in senior engineering interviews.
MongoDB multi-document transactions are powerful and correct — but they are not free. Use them precisely where atomicity across multiple documents is a hard requirement. For everything else, lean on single-document atomicity, schema design, and idempotent write patterns. The discipline of reaching for transactions only when necessary keeps your write paths fast and your codebase simpler.