Skip to content

Transactions

Execute multiple operations atomically—either all succeed or all fail.

Basic Usage

typescript
await Users.transaction(async (session) => {
  // All operations must include { session }
  const user = await Users.insertOne(
    { name: 'Alice', email: 'alice@example.com' },
    { session }
  );

  await Posts.insertOne(
    { title: 'First Post', authorId: user._id },
    { session }
  );

  // If any operation fails, all are rolled back
});

Requirements

Replica Set Required

MongoDB must be running as a replica set for transactions to work. This includes:

  • MongoDB Atlas (all clusters)
  • Local MongoDB with --replSet option
  • Docker with replica set configuration

Single-node deployments without replica set configuration do not support transactions.

Shared Client Requirement

All collections in a transaction must share the same MongoDB client:

typescript
import { MongoClient } from 'mongodb';

const client = new MongoClient('mongodb://localhost:27017');

const Users = collection({
  name: 'users',
  schema: { /* ... */ },
  client,
  database: 'myapp',
});

const Posts = collection({
  name: 'posts',
  schema: { /* ... */ },
  client,           // Same client
  database: 'myapp',
});

// Now transactions work across both collections
await Users.transaction(async (session) => {
  await Users.insertOne({ ... }, { session });
  await Posts.insertOne({ ... }, { session });
});

Return Values

Transactions can return values:

typescript
const result = await Users.transaction(async (session) => {
  const user = await Users.insertOne(
    { name: 'Alice', email: 'alice@example.com' },
    { session }
  );

  const post = await Posts.insertOne(
    { title: 'First Post', authorId: user._id },
    { session }
  );

  return { user, post };  // Return created documents
});

console.log(result.user._id);
console.log(result.post._id);

Error Handling

If any operation throws, the entire transaction is rolled back:

typescript
try {
  await Users.transaction(async (session) => {
    await Users.insertOne(
      { name: 'Alice', email: 'alice@example.com' },
      { session }
    );

    // This will fail and roll back the user insert
    throw new Error('Something went wrong');
  });
} catch (error) {
  console.log('Transaction failed:', error.message);
  // The user was NOT inserted
}

Common Patterns

Transfer Money

typescript
async function transferMoney(
  fromId: string,
  toId: string,
  amount: number
) {
  await Accounts.transaction(async (session) => {
    // Debit source account
    const from = await Accounts.updateOne(
      { _id: fromId, balance: { $gte: amount } },
      { $inc: { balance: -amount } },
      { session }
    );

    if (!from) {
      throw new Error('Insufficient funds');
    }

    // Credit destination account
    await Accounts.updateOne(
      { _id: toId },
      { $inc: { balance: amount } },
      { session }
    );

    // Log the transfer
    await Transfers.insertOne(
      {
        from: fromId,
        to: toId,
        amount,
        timestamp: new Date(),
      },
      { session }
    );
  });
}
typescript
async function createUserWithProfile(data: UserInput) {
  return await Users.transaction(async (session) => {
    const user = await Users.insertOne(
      { name: data.name, email: data.email },
      { session }
    );

    await Profiles.insertOne(
      {
        userId: user._id,
        bio: data.bio,
        avatar: data.avatar,
      },
      { session }
    );

    await Settings.insertOne(
      {
        userId: user._id,
        notifications: true,
        theme: 'light',
      },
      { session }
    );

    return user;
  });
}

Atomic Counter with History

typescript
async function incrementCounter(name: string) {
  return await Counters.transaction(async (session) => {
    const counter = await Counters.updateOne(
      { name },
      { $inc: { value: 1 } },
      { session, upsert: true }
    );

    await CounterHistory.insertOne(
      {
        counterName: name,
        newValue: counter?.value ?? 1,
        timestamp: new Date(),
      },
      { session }
    );

    return counter?.value ?? 1;
  });
}

Best Practices

  1. Keep transactions short - Long transactions hold locks and can cause contention

  2. Only use when necessary - Transactions have overhead. Use them only when atomicity is required

  3. Handle retries - Transient errors can occur. Consider retry logic for production:

typescript
async function withRetry<T>(
  fn: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  let lastError: Error;

  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error as Error;
      if (!isTransientError(error)) throw error;
      await sleep(Math.pow(2, i) * 100);  // Exponential backoff
    }
  }

  throw lastError!;
}

// Usage
await withRetry(() =>
  Users.transaction(async (session) => {
    // ... operations
  })
);
  1. Avoid transactions for single operations - A single insertOne or updateOne is already atomic

Limitations

  • Maximum transaction runtime: 60 seconds (default)
  • Transactions can span multiple collections but must use the same client
  • Some operations don't support transactions (e.g., creating indexes)
  • Transactions increase memory usage on the server

Released under the MIT License.