Since 4.0 on replica sets and 4.2 on sharded clusters, MongoDB supports multi-document ACID transactions. Interviewers rarely want to know whether transactions exist. They want to know whether you’ll reach for one by reflex, or whether you understand when the document model already gives you atomicity, what a transaction costs, and how to write retry logic that doesn’t double-apply.
Code is Node.js driver and mongosh style for MongoDB 7 and 8. It’s illustrative; no replica set was available while writing.
Start with the document model
A single-document write is already atomic. So before reaching for a transaction, ask: do these changes really touch several documents, or did the schema split something that belongs together?
An order and its line items: one document, one atomic write, no transaction. A shopping cart and its contents: one document. A blog post and its tag list: one document. If your design needs a transaction to keep an order’s header and lines consistent, the design is fighting MongoDB.
Genuine multi-document cases do exist:
- Moving money between two account documents.
- Decrementing inventory in
productsand inserting intoorders. - Changing a username in
usersand claiming it in ausernamescollection that enforces uniqueness across tenants.
These involve independent entities that are each read and written on their own, so merging them into one document would be wrong.
A transfer, written properly
const session = client.startSession()
try {
await session.withTransaction(async () => {
const accounts = client.db("bank").collection("accounts")
const debit = await accounts.updateOne(
{ _id: "acc-A", balance: { $gte: 100 } },
{ $inc: { balance: -100 } },
{ session }
)
if (debit.matchedCount === 0) throw new Error("insufficient funds")
await accounts.updateOne({ _id: "acc-B" }, { $inc: { balance: 100 } }, { session })
await client.db("bank").collection("ledger").insertOne(
{ from: "acc-A", to: "acc-B", amount: 100, at: new Date() }, { session })
}, {
readConcern: { level: "snapshot" },
writeConcern: { w: "majority" }
})
} finally {
await session.endSession()
}Things to point out in an interview:
- Every operation passes
{ session }. Forget it on one call and that write happens outside the transaction, committed immediately even if the rest aborts. This is the single most common transaction bug, and it doesn’t produce an error. - The balance check is in the filter. The transaction gives isolation, but the conditional update keeps the logic simple and still correct.
- Throwing aborts.
withTransactionaborts on an error thrown from the callback and doesn’t retry it, since “insufficient funds” isn’t transient. - Read and write concern are set on the transaction, not on individual operations inside it.
Retries: two different error labels
Transactions can fail for reasons that aren’t your fault. The driver attaches error labels:
TransientTransactionError: the transaction aborted (a write conflict with another transaction, a primary step-down, a network blip). Nothing was committed. Retry the whole transaction from the start.UnknownTransactionCommitResult: the commit was sent but the response was lost. The transaction may or may not have committed. Retry only the commit; the server handles a repeated commit for the same transaction correctly.
withTransaction implements both loops for you, retrying for up to 120 seconds by default. If you write the loop yourself with startTransaction/commitTransaction, you must handle both labels differently. Retrying the whole callback after an unknown commit result could apply the transfer twice. Because withTransaction may run your callback several times, the callback must not have side effects outside the database, like sending an email or calling a payment API.
What transactions cost
Write conflicts. MongoDB uses optimistic concurrency inside transactions. If two transactions modify the same document, the second one to write gets a WriteConflict and is aborted with a transient label. Under contention (a hot counter document everyone updates in a transaction), you get a storm of retries. A non-transactional $inc on that counter wouldn’t conflict at all.
Time limit. transactionLifetimeLimitSeconds defaults to 60. A transaction open longer is aborted. Long transactions also pin old snapshots in the WiredTiger cache, adding memory pressure for everyone. Keep them to a few small operations, and never wait for a user, a queue or an HTTP call inside one.
Locks on DDL. Creating indexes or collections inside a transaction has restrictions, and a transaction can block or be blocked by DDL on the same collection. Do schema changes outside.
Sharded overhead. A transaction touching several shards uses two-phase commit coordinated by one shard. It works, but the latency is higher than a single-shard transaction. If your common transaction always spans shards, revisit the shard key.
Size. There’s no longer a 16 MB limit on the total transaction (since 4.2 it’s split across oplog entries), but huge transactions still hold a lot of cache. Batch bulk changes outside transactions.
Alternatives worth naming
When a transaction seems heavy, interviewers like hearing that you know other options:
- Remodel so the invariant lives in one document.
- Conditional single-document updates for guards and counters.
- A state machine with idempotent steps: write an order as
pending, reserve stock with a conditional update, mark the orderconfirmed; a background job resolves stuckpendingorders. This is how many systems handle cross-service work anyway, since a database transaction can’t span a payment provider. - Outbox pattern: write the business change and an “event to publish” document in one transaction (or one document), and let a separate process publish events. That avoids sending messages from inside the transaction.
Interview practice
A ticketing system decrements events.seatsLeft and inserts a bookings document inside a transaction. Under a big on-sale with 20,000 concurrent buyers for one event, latency spikes and logs fill with WriteConflict. Why, and how would you redesign it?
Answer
Every transaction updates the same events document. MongoDB’s transactions detect conflicts per document, so with thousands of concurrent transactions writing that one document, nearly all of them conflict with whichever committed first, get aborted with TransientTransactionError, and withTransaction retries them. The retries conflict again. You end up with lots of wasted work and long tail latency, while throughput on that document is effectively serial.
I’d take the hot counter out of the transaction. A conditional single-document update, updateOne({ _id: eventId, seatsLeft: { $gte: qty } }, { $inc: { seatsLeft: -qty } }), is atomic and doesn’t abort others; contenders simply queue briefly on the document and the losers see matchedCount: 0 when it sells out. After a successful decrement, insert the booking with an idempotency key under a unique index. If the insert fails, compensate by incrementing the seats back. A background check reconciles seatsLeft against the count of bookings.
For even higher contention I’d split inventory across several documents (say, one per section or 10 shards of the seat count) so writers spread out. The point I’d make is that the transaction wasn’t wrong for correctness, but the model put all contention on one document, and a transaction makes that worse.