MongoDB has guaranteed atomicity for single-document operations since its inception — inserting or updating a single document either fully succeeds or fully fails. Since MongoDB 4.0, multi-document ACID transactions extend this guarantee to operations spanning multiple documents and multiple collections. This enables use cases like “transfer credit from one account to another” or “create an order and decrement inventory simultaneously” where partial failure would leave data in an inconsistent state. Understanding when to use transactions — and when MongoDB’s document model makes them unnecessary — is essential for building data-consistent applications in the MEAN Stack.
ACID Properties in MongoDB
| Property | Meaning | MongoDB Support |
|---|---|---|
| Atomicity | All operations in a transaction succeed or all fail — no partial state | Single-document: always. Multi-document: with transactions |
| Consistency | Data moves from one valid state to another — all constraints satisfied | Schema validation and unique indexes enforced |
| Isolation | Concurrent transactions don’t see each other’s uncommitted changes | Snapshot isolation (read your own writes within transaction) |
| Durability | Committed transactions survive crashes and restarts | Write concern { w: 'majority', j: true } ensures durability |
When to Use Transactions
| Use Transactions | Do NOT Need Transactions |
|---|---|
| Transfer funds between two accounts | Creating a user with embedded preferences |
| Create order + decrement inventory atomically | Updating a single task’s status |
| Publish post + create notifications simultaneously | Adding a comment to an embedded array |
| Register user + create default workspace | Reading data (transactions add overhead with no benefit) |
| Multi-collection rollback scenarios | Operations that naturally fit in a single document |
| Debit one collection, credit another atomically | Upserts and findOneAndUpdate (single-document atomic) |
Transaction Performance Considerations
| Factor | Impact |
|---|---|
| Lock contention | Transactions lock documents — high concurrency on same docs increases wait time |
| Transaction duration | Default 60s timeout — long transactions block other operations |
| Overhead | Transactions add ~3-5x overhead vs non-transactional operations |
| Replica set requirement | Transactions require a replica set or sharded cluster — standalone MongoDB does not support them |
| Abort on conflict | If two transactions conflict, one aborts and must retry |
findOneAndUpdate is atomic by definition. Transactions are most needed when you cannot avoid splitting genuinely atomic data across multiple documents. Before reaching for a transaction, ask: “Can I restructure the schema to make this a single-document operation?”errorLabels containing 'TransientTransactionError') is to retry the entire transaction from the start. Build a reusable withTransaction(fn) helper that handles retries automatically rather than writing retry logic in every place you use transactions.docker run -d --name mongodb mongo --replSet rs0 to run MongoDB as a single-node replica set. In production, always use a replica set — it provides both transaction support and automatic failover.Complete Transaction Implementation
// ── Mongoose transaction helper ───────────────────────────────────────────
const mongoose = require('mongoose');
/**
* Execute a function within a MongoDB transaction.
* Automatically retries on transient errors.
* @param {Function} fn - async function receiving (session) parameter
* @param {Object} options - mongoose session options
*/
async function withTransaction(fn, options = {}) {
const session = await mongoose.startSession();
let result;
try {
await session.withTransaction(async () => {
result = await fn(session);
}, {
readPreference: 'primary',
readConcern: { level: 'majority' },
writeConcern: { w: 'majority', j: true },
maxCommitTimeMS: 30000, // abort if commit takes more than 30s
...options,
});
} finally {
await session.endSession();
}
return result;
}
module.exports = { withTransaction };
// ── Example 1: Register user + create default workspace atomically ────────
const User = require('../models/user.model');
const Workspace = require('../models/workspace.model');
const { withTransaction } = require('../utils/transaction');
async function registerUser(userData) {
return withTransaction(async (session) => {
// Create the user within the transaction
const [user] = await User.create([{
name: userData.name,
email: userData.email,
password: userData.hashedPassword,
}], { session });
// Create default workspace atomically — if this fails, user is NOT created
const [workspace] = await Workspace.create([{
name: `${user.name}'s Workspace`,
owner: user._id,
members: [{ userId: user._id, role: 'owner' }],
}], { session });
// Link workspace to user — still in same transaction
await User.findByIdAndUpdate(
user._id,
{ $set: { defaultWorkspace: workspace._id } },
{ session }
);
return { user, workspace };
});
}
// ── Example 2: Transfer credit between accounts ───────────────────────────
const Account = require('../models/account.model');
async function transferCredits(fromAccountId, toAccountId, amount) {
return withTransaction(async (session) => {
// Debit source account
const fromAccount = await Account.findOneAndUpdate(
{ _id: fromAccountId, balance: { $gte: amount } }, // ensure sufficient balance
{ $inc: { balance: -amount } },
{ new: true, session }
);
if (!fromAccount) {
throw new Error('Insufficient balance or account not found');
}
// Credit destination account
const toAccount = await Account.findByIdAndUpdate(
toAccountId,
{ $inc: { balance: amount } },
{ new: true, session }
);
if (!toAccount) {
throw new Error('Destination account not found');
}
// Record the transfer in an audit collection
await Transfer.create([{
from: fromAccountId,
to: toAccountId,
amount,
timestamp: new Date(),
status: 'completed',
}], { session });
return { fromBalance: fromAccount.balance, toBalance: toAccount.balance };
});
}
// ── Example 3: Create order + decrement inventory ─────────────────────────
const Order = require('../models/order.model');
const Product = require('../models/product.model');
async function placeOrder(userId, cartItems) {
return withTransaction(async (session) => {
let totalAmount = 0;
const orderItems = [];
// Check and decrement inventory for each item atomically
for (const { productId, quantity } of cartItems) {
const product = await Product.findOneAndUpdate(
{ _id: productId, stock: { $gte: quantity } }, // check stock
{ $inc: { stock: -quantity } }, // decrement atomically
{ new: true, session }
);
if (!product) {
throw new Error(`Product ${productId} is out of stock or not found`);
// Transaction aborts — all stock decrements are rolled back
}
totalAmount += product.price * quantity;
orderItems.push({ productId, quantity, price: product.price });
}
// Create the order
const [order] = await Order.create([{
user: userId,
items: orderItems,
total: totalAmount,
status: 'confirmed',
}], { session });
return order;
});
}
// ── Example 4: Manual session management (alternative to withTransaction) ─
async function manualTransactionExample() {
const session = await mongoose.startSession();
session.startTransaction({
readConcern: { level: 'snapshot' },
writeConcern: { w: 'majority' },
});
try {
const task = await Task.create([{ title: 'New task', user: userId }], { session });
await User.findByIdAndUpdate(userId, { $inc: { taskCount: 1 } }, { session });
await session.commitTransaction();
return task[0];
} catch (err) {
await session.abortTransaction();
throw err;
} finally {
await session.endSession();
}
}
How It Works
Step 1 — Sessions Are the Transaction Carrier
A Mongoose session is the object that ties operations together into a transaction. Pass { session } as an option to every Mongoose operation that should be part of the transaction: Model.create([doc], { session }), Model.findOneAndUpdate(filter, update, { session }), Model.deleteOne(filter, { session }). Operations without the session option are outside the transaction and execute independently.
Step 2 — session.withTransaction Handles Retry and Commit
session.withTransaction(fn) is the recommended way to run transactions. It automatically retries the entire function if a transient error occurs (write conflict), and commits when the function completes without throwing. If the function throws a non-transient error, it aborts the transaction. This wrapper eliminates the need to manually call startTransaction, commitTransaction, and abortTransaction in most cases.
Step 3 — All Operations See a Consistent Snapshot
Inside a transaction, all reads see the state of the database at the moment the transaction started — even if other transactions commit changes after the transaction begins. This snapshot isolation means your logic operates on consistent data: “if the account had $100 when I started, I can rely on that $100 being there when I debit it, even if a concurrent transaction is also running.” Reads outside the transaction see the current committed state.
Step 4 — Abort Rolls Back All Operations in the Transaction
If any operation throws inside a transaction — a validation error, a “not found” error you throw explicitly, a network error, a write conflict — and you call session.abortTransaction() (or session.withTransaction handles it), all modifications made during the transaction are reversed. It is as if none of the operations happened. The database returns to the state it was in before the transaction started.
Step 5 — create() Must Receive an Array When Using Sessions
This is a Mongoose-specific gotcha: when using a session, Model.create(doc, { session }) does not work as expected — the second argument is treated differently. You must pass an array: Model.create([doc], { session }). This is because Mongoose’s create() with a session internally uses insertMany(), which expects an array. The return value is also an array — destructure the first element: const [newDoc] = await Model.create([doc], { session }).
Real-World Example: User Registration Service with Transaction
// services/auth.service.js
const bcrypt = require('bcryptjs');
const User = require('../models/user.model');
const Workspace = require('../models/workspace.model');
const { withTransaction } = require('../utils/transaction');
const emailQueue = require('../queues').emailQueue;
async function register({ name, email, password }) {
// Check if email exists BEFORE starting transaction (avoid locking)
const existing = await User.findOne({ email }).lean();
if (existing) throw new ConflictError('Email already registered');
// Hash password outside transaction (CPU-intensive, no DB lock needed)
const hashedPassword = await bcrypt.hash(password, 12);
// Atomic: create user + workspace together or neither
const { user } = await withTransaction(async (session) => {
const [user] = await User.create([{
name,
email,
password: hashedPassword,
role: 'user',
}], { session });
// Create personal workspace
const [workspace] = await Workspace.create([{
name: 'My Workspace',
owner: user._id,
members: [{ userId: user._id, role: 'owner' }],
}], { session });
await User.findByIdAndUpdate(
user._id,
{ $set: { defaultWorkspace: workspace._id } },
{ session, new: true }
);
return { user, workspace };
});
// Send welcome email AFTER transaction commits (non-transactional side effect)
await emailQueue.add('welcome', { userId: user._id, email, name });
return user;
}
module.exports = { register };
Common Mistakes
Mistake 1 — Using Model.create(doc, { session }) instead of create([doc], { session })
❌ Wrong — session not applied correctly:
const user = await User.create({ name, email }, { session });
// Mongoose ignores the session — operation runs OUTSIDE the transaction!
✅ Correct — always pass an array when using sessions with create():
const [user] = await User.create([{ name, email }], { session });
// Session correctly applied — operation is part of the transaction
Mistake 2 — Starting a transaction on a standalone MongoDB (no replica set)
❌ Wrong — throws MongoServerError in development:
Error: Transaction numbers are only allowed on a replica member or mongos,
but this connection is to a standalone
✅ Correct — run a single-node replica set in development:
docker run -d --name mongodb -p 27017:27017 mongo --replSet rs0
docker exec mongodb mongosh --eval "rs.initiate()"
Mistake 3 — Not retrying on TransientTransactionError
❌ Wrong — first write conflict causes permanent failure:
const session = await mongoose.startSession();
session.startTransaction();
try {
await doWork(session);
await session.commitTransaction();
} catch (err) {
await session.abortTransaction();
throw err; // no retry — just fails
}
✅ Correct — use session.withTransaction() which retries transient errors automatically:
const session = await mongoose.startSession();
await session.withTransaction(async () => {
await doWork(session); // retried automatically on write conflicts
});
await session.endSession();
Quick Reference
| Task | Code |
|---|---|
| Start session | const session = await mongoose.startSession() |
| Run transaction | await session.withTransaction(async () => { ... }) |
| Create in transaction | const [doc] = await Model.create([data], { session }) |
| Find in transaction | Model.findById(id).session(session) |
| Update in transaction | Model.findByIdAndUpdate(id, update, { session }) |
| End session | await session.endSession() |
| Manual abort | await session.abortTransaction() |
| Manual commit | await session.commitTransaction() |
| Reusable wrapper | withTransaction(async (session) => { ... }) |