MongoDB transactions bring ACID guarantees to multi-document operations — the same “all-or-nothing” semantics that relational databases have provided for decades. Without transactions, a sequence of MongoDB writes (debit account A, credit account B, write audit log) can partially complete if the process crashes midway, leaving data in an inconsistent state. With transactions, the entire sequence succeeds or the entire sequence is rolled back, and no other operation sees any intermediate state. Transactions are supported on replica sets and sharded clusters — not on standalone MongoDB instances.
ACID Properties in MongoDB
| Property | MongoDB Guarantee |
|---|---|
| Atomicity | All writes in a transaction succeed or all are rolled back — no partial commits |
| Consistency | Document validation rules and schema validation are enforced across all writes |
| Isolation | Snapshot isolation — transactions see a consistent view of data from transaction start |
| Durability | Committed transactions are persisted to replica set majority before acknowledgement |
Task.create([data], { session }) and User.findByIdAndUpdate(id, update, { session }) — without passing the session, the operation executes outside the transaction and commits immediately, regardless of whether the transaction later succeeds or rolls back. This is the most common transaction bug and is completely silent — the write commits even though the transaction rolls back.Complete Transaction Implementation
const mongoose = require('mongoose');
const Task = require('../models/task.model');
const User = require('../models/user.model');
const TaskLog = require('../models/task-log.model');
// ── Helper: run a function in a transaction with auto-retry ───────────────
async function withTransaction(fn, options = {}) {
const session = await mongoose.startSession();
try {
let result;
await session.withTransaction(async () => {
result = await fn(session);
}, {
readPreference: 'primary',
readConcern: { level: 'majority' },
writeConcern: { w: 'majority' },
maxCommitTimeMS: 5000,
...options,
});
return result;
} finally {
session.endSession();
}
}
// ── Transfer task ownership atomically ────────────────────────────────────
async function transferTask(taskId, fromUserId, toUserId) {
return withTransaction(async session => {
// 1. Find and lock the task
const task = await Task.findOne(
{ _id: taskId, user: fromUserId },
null,
{ session }
);
if (!task) throw new Error('Task not found or not owned by user');
// 2. Decrement from-user task count
const fromUpdate = await User.findByIdAndUpdate(
fromUserId,
{ $inc: { taskCount: -1 } },
{ session, new: true }
);
if (!fromUpdate) throw new Error('Source user not found');
// 3. Increment to-user task count
const toUpdate = await User.findByIdAndUpdate(
toUserId,
{ $inc: { taskCount: 1 } },
{ session, new: true }
);
if (!toUpdate) throw new Error('Target user not found');
// 4. Update task ownership
task.user = toUserId;
await task.save({ session });
// 5. Write audit log
await TaskLog.create([{
taskId,
action: 'transferred',
fromUser: fromUserId,
toUser: toUserId,
timestamp: new Date(),
}], { session });
return { task, fromTaskCount: fromUpdate.taskCount, toTaskCount: toUpdate.taskCount };
});
}
// ── Bulk create with rollback on any failure ──────────────────────────────
async function bulkCreateTasks(tasksData, userId) {
return withTransaction(async session => {
const created = [];
for (const data of tasksData) {
const [task] = await Task.create(
[{ ...data, user: userId }],
{ session }
);
created.push(task);
}
// Update user's task count in same transaction
await User.findByIdAndUpdate(
userId,
{ $inc: { taskCount: created.length } },
{ session }
);
return created;
});
}
// ── Manual session management (when withTransaction isn't enough) ──────────
async function complexOperation(data) {
const session = await mongoose.startSession();
session.startTransaction({
readConcern: { level: 'snapshot' },
writeConcern: { w: 'majority' },
});
try {
// All writes must include { session }
const task = await Task.findById(data.taskId).session(session);
if (!task) throw new Error('Task not found');
// Read-modify-write — safe because snapshot isolation
task.status = 'completed';
task.completedAt = new Date();
await task.save({ session });
await User.updateOne(
{ _id: task.user },
{ $inc: { completedTaskCount: 1 } },
{ session }
);
await session.commitTransaction();
return task;
} catch (err) {
await session.abortTransaction();
throw err;
} finally {
session.endSession();
}
}
How It Works
Step 1 — session.withTransaction() Handles Retry and Cleanup
The withTransaction() helper on the session automatically retries transient errors — specifically TransientTransactionError and UnknownTransactionCommitResult — which can occur due to network issues or write conflicts in high-concurrency scenarios. It also handles commit, abort, and session cleanup automatically. Using this helper is safer than manual transaction management, where a developer might forget to abort on error or end the session in the finally block.
Step 2 — Snapshot Isolation Prevents Read Skew
With snapshot isolation (read concern snapshot), every read inside the transaction sees data as it was at the moment the transaction started — even if other transactions commit changes to those documents during your transaction. This prevents “read skew” where two reads of the same document in one transaction see different values. It also means your transaction can fail to commit if another transaction modified the same documents — MongoDB detects the write conflict and aborts.
Step 3 — { session } Must Be Passed to Every Operation
Mongoose operations without a session option execute outside the transaction — they use a new implicit session and commit immediately. A Task.create() without { session } inside a transaction that later aborts will still persist the created task, breaking atomicity. Mongoose’s .session(session) method on queries and the { session } option on all write methods are the two ways to bind an operation to a session.
Step 4 — Transactions Require Replica Sets
MongoDB multi-document transactions use the oplog (operations log) of the replica set to coordinate the two-phase commit. A standalone MongoDB instance does not have a replica set and therefore cannot support transactions. In development with Docker, use a single-node replica set: start MongoDB with --replSet rs0 and initialise it with rs.initiate(). The mongo-memory-server package used in tests supports replica sets via the replSet option.
Step 5 — Transactions Should Be Short-Lived
Each document modified in a transaction holds an intent lock until commit or abort. Long-running transactions accumulate locks and block other operations on the same documents. The 60-second default timeout is a safety net, not a target. Design transactions to execute in milliseconds — read what you need, make the writes, commit. If a transaction needs to process thousands of documents, split it into smaller batches or use a background job pattern.
Quick Reference
| Task | Code |
|---|---|
| Start session | const session = await mongoose.startSession() |
| Run transaction | await session.withTransaction(async () => { ... }) |
| Pass session to find | Task.findById(id).session(session) |
| Pass session to write | Task.create([data], { session }) |
| Pass session to update | Task.findByIdAndUpdate(id, update, { session }) |
| Manual commit | await session.commitTransaction() |
| Manual abort | await session.abortTransaction() |
| Always end session | finally { session.endSession() } |