MongoDB Transactions: Multi-Document ACID

• MongoDB transactions provide ACID guarantees across multiple documents and collections since version 4.0, eliminating the need for application-level compensating transactions in complex operations

Key Insights

• MongoDB transactions provide ACID guarantees across multiple documents and collections since version 4.0, eliminating the need for application-level compensating transactions in complex operations • Transaction performance degrades with document contention and long-running operations—design your schema to minimize cross-document updates and keep transactions under 60 seconds • Replica sets require specific configuration (write concern majority, read concern snapshot) and proper error handling with retry logic to handle transient failures like TransientTransactionError

Why Multi-Document Transactions Matter

MongoDB’s document model typically allows you to embed related data within a single document, making many operations atomic by default. However, real-world applications often require coordinated updates across multiple documents or collections—transferring funds between accounts, maintaining inventory across warehouses, or updating user profiles alongside audit logs.

Before MongoDB 4.0, developers implemented application-level compensating transactions or two-phase commits. These patterns added complexity and couldn’t guarantee true ACID properties. Native transactions eliminate this burden while providing snapshot isolation and all-or-nothing execution guarantees.

Setting Up Your Replica Set

Transactions require a replica set configuration, even for development. Here’s a minimal setup using Docker Compose:

version: '3.8'
services:
  mongo1:
    image: mongo:7.0
    command: mongod --replSet rs0 --port 27017
    ports:
      - "27017:27017"
    volumes:
      - mongo1_data:/data/db
    networks:
      - mongo-cluster

volumes:
  mongo1_data:

networks:
  mongo-cluster:

Initialize the replica set:

// Connect to MongoDB and run in mongosh
rs.initiate({
  _id: "rs0",
  members: [
    { _id: 0, host: "localhost:27017" }
  ]
})

Basic Transaction Implementation

Here’s a practical example of a banking transfer using the Node.js driver:

const { MongoClient } = require('mongodb');

async function transferFunds(fromAccount, toAccount, amount) {
  const client = new MongoClient('mongodb://localhost:27017/?replicaSet=rs0');
  
  try {
    await client.connect();
    const session = client.startSession();
    
    try {
      await session.withTransaction(async () => {
        const db = client.db('banking');
        const accounts = db.collection('accounts');
        
        // Debit source account
        const debitResult = await accounts.updateOne(
          { 
            accountId: fromAccount,
            balance: { $gte: amount }
          },
          { $inc: { balance: -amount } },
          { session }
        );
        
        if (debitResult.matchedCount === 0) {
          throw new Error('Insufficient funds or account not found');
        }
        
        // Credit destination account
        await accounts.updateOne(
          { accountId: toAccount },
          { $inc: { balance: amount } },
          { session }
        );
        
        // Record transaction
        await db.collection('transactions').insertOne({
          from: fromAccount,
          to: toAccount,
          amount: amount,
          timestamp: new Date()
        }, { session });
        
      }, {
        readConcern: { level: 'snapshot' },
        writeConcern: { w: 'majority' },
        readPreference: 'primary'
      });
      
      console.log('Transfer completed successfully');
      
    } finally {
      await session.endSession();
    }
  } finally {
    await client.close();
  }
}

Handling Transaction Errors

Transactions can fail due to write conflicts, network issues, or primary elections. Implement proper retry logic:

async function executeWithRetry(transactionFunc, maxRetries = 3) {
  let attempt = 0;
  
  while (attempt < maxRetries) {
    try {
      return await transactionFunc();
    } catch (error) {
      attempt++;
      
      // Retry on transient errors
      if (error.hasErrorLabel('TransientTransactionError') && 
          attempt < maxRetries) {
        console.log(`Retrying transaction, attempt ${attempt}`);
        await new Promise(resolve => setTimeout(resolve, 100 * attempt));
        continue;
      }
      
      // Retry commit on unknown commit result
      if (error.hasErrorLabel('UnknownTransactionCommitResult') && 
          attempt < maxRetries) {
        console.log('Unknown commit result, retrying');
        await new Promise(resolve => setTimeout(resolve, 100 * attempt));
        continue;
      }
      
      throw error;
    }
  }
}

// Usage
await executeWithRetry(() => transferFunds('ACC001', 'ACC002', 100));

Cross-Collection Inventory Management

A more complex example demonstrating transactions across multiple collections:

async function processOrder(orderId, items) {
  const client = new MongoClient('mongodb://localhost:27017/?replicaSet=rs0');
  
  await client.connect();
  const session = client.startSession();
  
  try {
    const result = await session.withTransaction(async () => {
      const db = client.db('ecommerce');
      const orders = db.collection('orders');
      const inventory = db.collection('inventory');
      const reservations = db.collection('reservations');
      
      // Validate and reserve inventory
      for (const item of items) {
        const stock = await inventory.findOne(
          { sku: item.sku },
          { session }
        );
        
        if (!stock || stock.available < item.quantity) {
          throw new Error(`Insufficient stock for SKU: ${item.sku}`);
        }
        
        // Decrement available inventory
        await inventory.updateOne(
          { sku: item.sku },
          { 
            $inc: { 
              available: -item.quantity,
              reserved: item.quantity 
            }
          },
          { session }
        );
        
        // Create reservation record
        await reservations.insertOne({
          orderId: orderId,
          sku: item.sku,
          quantity: item.quantity,
          expiresAt: new Date(Date.now() + 15 * 60 * 1000)
        }, { session });
      }
      
      // Create order
      await orders.insertOne({
        _id: orderId,
        items: items,
        status: 'pending',
        createdAt: new Date()
      }, { session });
      
      return { orderId, status: 'reserved' };
    });
    
    return result;
    
  } finally {
    await session.endSession();
    await client.close();
  }
}

Performance Considerations

Transaction performance depends on several factors. Keep these guidelines in mind:

// BAD: Long-running transaction with external API call
await session.withTransaction(async () => {
  await collection.updateOne({...}, {...}, { session });
  await fetch('https://external-api.com/notify'); // Don't do this
  await collection.insertOne({...}, { session });
});

// GOOD: Keep transactions focused on database operations
const result = await session.withTransaction(async () => {
  await collection.updateOne({...}, {...}, { session });
  await collection.insertOne({...}, { session });
  return { success: true };
});

// Make external calls after transaction commits
if (result.success) {
  await fetch('https://external-api.com/notify');
}

Monitor transaction metrics:

// Enable profiling to track slow transactions
db.setProfilingLevel(1, { slowms: 100 });

// Query slow transactions
db.system.profile.find({
  'command.lsid': { $exists: true },
  millis: { $gt: 100 }
}).sort({ ts: -1 }).limit(10);

Transaction Limitations and Workarounds

MongoDB transactions have specific constraints you must design around:

16MB document size limit: If your transaction modifies documents that together exceed 16MB, split the operation or redesign your schema.

60-second default timeout: Configure transactionLifetimeLimitSeconds if needed, but prefer shorter transactions.

No DDL operations: You cannot create collections or indexes inside transactions. Create them beforehand:

async function ensureCollectionsExist(db) {
  const collections = await db.listCollections().toArray();
  const collectionNames = collections.map(c => c.name);
  
  if (!collectionNames.includes('accounts')) {
    await db.createCollection('accounts');
  }
  if (!collectionNames.includes('transactions')) {
    await db.createCollection('transactions');
  }
}

// Call before starting transactions
await ensureCollectionsExist(db);

Monitoring Transaction Health

Implement observability for transaction performance:

async function monitoredTransaction(transactionFunc, metadata = {}) {
  const startTime = Date.now();
  const session = client.startSession();
  
  try {
    const result = await session.withTransaction(transactionFunc);
    
    const duration = Date.now() - startTime;
    console.log('Transaction succeeded', {
      ...metadata,
      duration,
      sessionId: session.id
    });
    
    return result;
  } catch (error) {
    const duration = Date.now() - startTime;
    console.error('Transaction failed', {
      ...metadata,
      duration,
      error: error.message,
      errorLabels: error.errorLabels
    });
    throw error;
  } finally {
    await session.endSession();
  }
}

Multi-document transactions in MongoDB provide the ACID guarantees required for complex business operations while maintaining the flexibility of the document model. Design your schema to minimize cross-document updates, implement robust retry logic, and monitor transaction performance to build reliable distributed systems.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.