Transactions
MongoDB transactions guarantee atomicity across multiple documents and collections — opt-in, since pre-4.0 Mongo did not have them. Use them when a multi-step write must succeed or fail together (place order + reserve stock). The cost: more round trips, requires a replica set or sharded cluster.
Sessions, withTransaction, retries, sharded considerations
EXAMPLE
// 1) Session-based transaction (Node driver)
const { MongoClient } = require('mongodb');
const client = new MongoClient(process.env.MONGO_URI, {
// replica set or sharded cluster required for transactions
});
await client.connect();
const session = client.startSession();
try {
session.startTransaction({
readConcern: { level: 'snapshot' },
writeConcern: { w: 'majority' },
});
const orders = client.db('shop').collection('orders');
const stock = client.db('shop').collection('stock');
await orders.insertOne(
{ _id: 'o1', customer: 'alice', items: [{ sku: 'sku-1', qty: 2 }], status: 'new' },
{ session },
);
const dec = await stock.updateOne(
{ sku: 'sku-1', qty: { $gte: 2 } },
{ $inc: { qty: -2 } },
{ session },
);
if (dec.matchedCount === 0) {
throw new Error('insufficient stock');
}
await session.commitTransaction();
} catch (e) {
await session.abortTransaction();
throw e;
} finally {
await session.endSession();
}
// 2) withTransaction — built-in retry on transient errors
async function placeOrder(client, order) {
const session = client.startSession();
try {
await session.withTransaction(async () => {
const orders = client.db('shop').collection('orders');
const stock = client.db('shop').collection('stock');
await orders.insertOne(order, { session });
for (const item of order.items) {
const r = await stock.updateOne(
{ sku: item.sku, qty: { $gte: item.qty } },
{ $inc: { qty: -item.qty } },
{ session },
);
if (r.matchedCount === 0) throw new Error('insufficient stock for ' + item.sku);
}
}, {
readConcern: { level: 'snapshot' },
writeConcern: { w: 'majority' },
readPreference: 'primary',
});
} finally {
await session.endSession();
}
}
// withTransaction retries on:
// - TransientTransactionError (driver tags transient errors)
// - UnknownTransactionCommitResult (commit succeeded but client did not get the ack)
// 3) Read concerns + write concerns
// readConcern: 'snapshot' — see a consistent point-in-time view inside the tx
// writeConcern: w='majority' — wait until a majority of replicas acknowledge
// These are the SAFE defaults. Faster but less safe options exist.
// 4) Limits + considerations
// - Single-shard transactions complete in milliseconds
// - Cross-shard (distributed) transactions are slower; default timeout = 60s
// - Total tx oplog size limited; do not run huge batch updates in one tx
// - Cannot create/drop collections inside a transaction (cluster-level operations)
// - Index builds are NOT part of the tx
// 5) Alternatives to transactions
// - Document-level atomicity: most writes are single-document AND atomic on _id
// - Embedded data: put related state in one document; no tx needed
// - Outbox pattern: write 'event' rows alongside main data, separate worker publishes
// - Compensation: on failure, run an inverse update
// Reach for transactions when business invariants span MULTIPLE documents
// AND you cannot model them with embedding.
// 6) Performance tips
// - Keep transactions SHORT. Batch outside; commit fast inside.
// - Avoid network calls inside the tx (each round-trip extends the lock window).
// - Set txnLifetimeLimitSeconds if your transactions can run long (default 60s).
// - On sharded clusters, pin the tx to one shard when possible.
// 7) Atomicity at single-doc level (no tx needed)
await orders.findOneAndUpdate(
{ _id: 'o1', status: 'new' },
{ $set: { status: 'paid', paid_at: new Date() } },
);
// findOneAndUpdate is atomic on one document; great for state machines.
// 8) Pitfalls
// - Using transactions on a standalone (no replica set) -> error
// - Forgetting endSession -> session leak on the server
// - Throwing INSIDE withTransaction without surfacing -> silent abort
// - Long transactions blocking writes on the same documents
// - Mixing tx + non-tx writes in the same workflow
Why it matters
Reach for transactions only when business invariants genuinely span multiple documents AND you cannot embed the data. Embedding + single-document atomicity covers most cases at lower cost. When you do need transactions, use `withTransaction` so the driver retries transient errors and commit-ack failures automatically — that retry alone fixes a class of bugs you would otherwise discover by chasing race conditions in production.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
const session = client.startSession();
await session.withTransaction(async () => {
await accounts.updateOne({ _id: from }, { $inc: { bal: -100 } }, { session });
await accounts.updateOne({ _id: to }, { $inc: { bal: 100 } }, { session });
});
Try it Yourself »
Discussion
Loading…