MongoDB Transactions — ACID Guarantees and Multi-Document Atomicity

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
Note: MongoDB transactions have a 60-second default timeout and a 16MB data change limit per transaction. Transactions should be short-lived — start, write, commit in under a second if possible. Long transactions hold locks on modified documents, blocking other operations. If you find yourself needing a 10-second transaction to process many documents, consider restructuring the operation: batch smaller transactions, use change streams to process asynchronously, or use a saga pattern for long-running workflows.
Tip: Always pass the session object to every Mongoose/MongoDB operation inside a transaction. 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.
Warning: Transactions are not free — they add latency (due to the two-phase commit protocol), increase lock contention, and require replica sets. Use transactions only when you genuinely need multi-document atomicity: financial operations, inventory management, any operation where partial writes produce inconsistent state. For simple denormalised data updates where partial writes are acceptable (updating a cached count field alongside the actual document), skip the transaction and handle inconsistency with background reconciliation jobs.

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() }

🧠 Test Yourself

Inside a transaction, Task.create({ title: 'Test', user: id }) is called without the { session } option. The transaction later aborts due to an error. What happens to the created task?