iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Transactions

MongoDB transactions guarantee atomicity across multiple documents and collections — opt-in, since pre-4.0 Mongo did not have them. Use them when a multi-step write must succeed or fail together (place order + reserve stock). The cost: more round trips, requires a replica set or sharded cluster.

Sessions, withTransaction, retries, sharded considerations

EXAMPLE
// 1) Session-based transaction (Node driver)
const { MongoClient } = require('mongodb');

const client = new MongoClient(process.env.MONGO_URI, {
  // replica set or sharded cluster required for transactions
});
await client.connect();

const session = client.startSession();
try {
  session.startTransaction({
    readConcern:  { level: 'snapshot' },
    writeConcern: { w: 'majority' },
  });

  const orders = client.db('shop').collection('orders');
  const stock  = client.db('shop').collection('stock');

  await orders.insertOne(
    { _id: 'o1', customer: 'alice', items: [{ sku: 'sku-1', qty: 2 }], status: 'new' },
    { session },
  );

  const dec = await stock.updateOne(
    { sku: 'sku-1', qty: { $gte: 2 } },
    { $inc: { qty: -2 } },
    { session },
  );
  if (dec.matchedCount === 0) {
    throw new Error('insufficient stock');
  }

  await session.commitTransaction();
} catch (e) {
  await session.abortTransaction();
  throw e;
} finally {
  await session.endSession();
}

// 2) withTransaction — built-in retry on transient errors
async function placeOrder(client, order) {
  const session = client.startSession();
  try {
    await session.withTransaction(async () => {
      const orders = client.db('shop').collection('orders');
      const stock  = client.db('shop').collection('stock');

      await orders.insertOne(order, { session });

      for (const item of order.items) {
        const r = await stock.updateOne(
          { sku: item.sku, qty: { $gte: item.qty } },
          { $inc: { qty: -item.qty } },
          { session },
        );
        if (r.matchedCount === 0) throw new Error('insufficient stock for ' + item.sku);
      }
    }, {
      readConcern:  { level: 'snapshot' },
      writeConcern: { w: 'majority' },
      readPreference: 'primary',
    });
  } finally {
    await session.endSession();
  }
}

// withTransaction retries on:
// - TransientTransactionError (driver tags transient errors)
// - UnknownTransactionCommitResult (commit succeeded but client did not get the ack)

// 3) Read concerns + write concerns
// readConcern: 'snapshot' — see a consistent point-in-time view inside the tx
// writeConcern: w='majority' — wait until a majority of replicas acknowledge
// These are the SAFE defaults. Faster but less safe options exist.

// 4) Limits + considerations
// - Single-shard transactions complete in milliseconds
// - Cross-shard (distributed) transactions are slower; default timeout = 60s
// - Total tx oplog size limited; do not run huge batch updates in one tx
// - Cannot create/drop collections inside a transaction (cluster-level operations)
// - Index builds are NOT part of the tx

// 5) Alternatives to transactions
// - Document-level atomicity: most writes are single-document AND atomic on _id
// - Embedded data: put related state in one document; no tx needed
// - Outbox pattern: write 'event' rows alongside main data, separate worker publishes
// - Compensation: on failure, run an inverse update
// Reach for transactions when business invariants span MULTIPLE documents
// AND you cannot model them with embedding.

// 6) Performance tips
// - Keep transactions SHORT. Batch outside; commit fast inside.
// - Avoid network calls inside the tx (each round-trip extends the lock window).
// - Set txnLifetimeLimitSeconds if your transactions can run long (default 60s).
// - On sharded clusters, pin the tx to one shard when possible.

// 7) Atomicity at single-doc level (no tx needed)
await orders.findOneAndUpdate(
  { _id: 'o1', status: 'new' },
  { $set: { status: 'paid', paid_at: new Date() } },
);
// findOneAndUpdate is atomic on one document; great for state machines.

// 8) Pitfalls
// - Using transactions on a standalone (no replica set) -> error
// - Forgetting endSession -> session leak on the server
// - Throwing INSIDE withTransaction without surfacing -> silent abort
// - Long transactions blocking writes on the same documents
// - Mixing tx + non-tx writes in the same workflow

Why it matters

Reach for transactions only when business invariants genuinely span multiple documents AND you cannot embed the data. Embedding + single-document atomicity covers most cases at lower cost. When you do need transactions, use `withTransaction` so the driver retries transient errors and commit-ack failures automatically — that retry alone fixes a class of bugs you would otherwise discover by chasing race conditions in production.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
const session = client.startSession();
await session.withTransaction(async () => {
    await accounts.updateOne({ _id: from }, { $inc: { bal: -100 } }, { session });
    await accounts.updateOne({ _id: to },   { $inc: { bal:  100 } }, { session });
});
Try it Yourself »

Discussion

Loading…