Transactions — Multi-Document ACID Transactions

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
Note: MongoDB’s document model often makes transactions unnecessary. If you embed related data in a single document — a task with its attachments, a user with their preferences — a single 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?”
Tip: Always wrap transaction logic in a retry loop. MongoDB transactions can abort due to write conflicts (two transactions modifying the same document simultaneously). The correct response to a write conflict error (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.
Warning: Transactions require a MongoDB replica set or sharded cluster. A standalone MongoDB server (single instance, no replication) does not support multi-document transactions. In development, use 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) => { ... })

🧠 Test Yourself

You call const user = await User.create({ name, email }, { session }) inside a transaction. The transaction commits. But checking the database shows the user was saved without the transaction’s changes being rolled back on failure. What is the bug?