Lesson 16 of 18

Transactions

The Problem Transactions Solve

Some operations are only correct if every part of them happens. Transferring money means subtracting from one account and adding to another; if the process crashes between those two writes, money has disappeared. Placing an order means reducing stock, creating an order document and recording a payment; a failure in the middle leaves stock reduced for an order that does not exist.

A transaction groups several operations so that either all of them take effect or none of them do. If anything fails partway, the database undoes what has already been done and the data is left exactly as it was before you started. Other users never see the half-finished state.

The four letters usually attached to this are ACID. Atomicity is the all-or-nothing property just described. Consistency means the data still satisfies your rules afterwards. Isolation means concurrent transactions do not see each other's unfinished work. Durability means once the database says a transaction committed, it survives a crash.

Example
// Without a transaction, a crash here is a real problem
db.accounts.updateOne({ _id: "A" }, { $inc: { balance: -5000 } })
// <-- process dies here: 5000 rupees have vanished
db.accounts.updateOne({ _id: "B" }, { $inc: { balance:  5000 } })
Notes
  • Before reaching for a transaction, check whether you need one. A write to a single document is already atomic in MongoDB, however many fields or array elements it touches. Transactions exist for the cases that genuinely span two or more documents.

You Often Do Not Need One

This is the most useful thing to learn about transactions, because reaching for one out of habit makes an application slower and more complicated for no benefit. MongoDB guarantees that an update to a single document happens completely or not at all — so if the fields that must change together live in the same document, you are already safe.

That is one of the strongest arguments for embedding. An order and its line items in one document means "add an item and update the total" is a single atomic update. Split across two collections, the same change needs a transaction.

Two operators cover a surprising number of the remaining cases. $inc performs arithmetic inside the database, so two simultaneous decrements of a stock count cannot lose one another. And a conditional update — filtering on the state you expect and updating in the same call — either matches and applies, or matches nothing, which is how you take stock only if stock is actually available. Check modifiedCount to find out which happened.

Example
// Atomic without a transaction: one document, one update
db.orders.updateOne(
  { _id: orderId },
  {
    $push: { items: { sku: "AB-1", qty: 1, price: 799 } },
    $inc:  { total: 799, itemCount: 1 }
  }
)

// Conditional update: only reserve stock if there is enough,
// with no read-then-write gap for another request to slip into
const res = db.products.updateOne(
  { sku: "AB-1", stock: { $gte: 2 } },
  { $inc: { stock: -2 } }
)

if (res.modifiedCount === 0) {
  print("Out of stock — nothing was changed")
}
Notes
  • Reading a value, changing it in JavaScript and writing it back is the pattern that creates lost updates under concurrency. Wherever you can express the change as an operator ($inc, $push, $addToSet) applied with a filter that encodes your assumption, do that instead.

Writing a Transaction

A transaction runs inside a session. You start a session, begin a transaction on it, perform your operations, and then either commit or abort. The recommended way to write this is withTransaction(), which handles the commit and the abort for you and — importantly — retries the whole block automatically when the server reports a temporary conflict.

That retry behaviour is why withTransaction is preferred over calling startTransaction, commitTransaction and abortTransaction by hand. Two transactions touching the same document will sometimes collide, and MongoDB signals this as a transient error that is expected to succeed on a second attempt. Code that does not retry turns a normal, recoverable event into a failed order.

Because the block can run more than once, everything inside it must be safe to repeat. Keep it to database operations. Do not send an email, charge a card or call another service from inside a transaction — the database can roll back its own writes, but it cannot un-send an email, and a retry would send a second one.

Example
import mongoose from 'mongoose';

const session = await mongoose.startSession();
try {
  await session.withTransaction(async () => {
    // every operation must be given the session
    const debited = await Account.updateOne(
      { _id: fromId, balance: { $gte: amount } },
      { $inc: { balance: -amount } },
      { session }
    );
    if (debited.modifiedCount === 0) throw new Error('Insufficient balance');

    await Account.updateOne(
      { _id: toId },
      { $inc: { balance: amount } },
      { session }
    );

    // Model.create inside a transaction takes an array
    await Transfer.create([{ from: fromId, to: toId, amount, at: new Date() }], { session });
  });
  console.log('Transfer complete');
} catch (err) {
  console.error('Transfer rolled back:', err.message);
} finally {
  await session.endSession();
}
Notes
  • Throwing an error inside the block is how you abort deliberately. The balance check above does exactly that: if the debit matched nothing, the thrown error rolls back everything, and the caller receives a clear message instead of a half-completed transfer.

The Mistake Everyone Makes Once

Every single operation that belongs to the transaction must be passed the session. Miss it on one line and that operation executes outside the transaction: it commits immediately, it is visible to everyone else straight away, and it will not be rolled back when the rest fails.

Nothing warns you. The code runs, the tests pass on happy paths, and the bug only appears the day something fails halfway through — which is exactly the scenario the transaction was added to protect against. Reviewing a transaction block line by line for a missing { session } is a habit worth building.

Reads need the session too. A find issued without it does not see the transaction's own uncommitted writes, so code that inserts a document and then looks for it will not find it. Inside a transaction, treat the session as required on everything.

Example
await session.withTransaction(async () => {
  await Order.create([orderDoc], { session });          // in the transaction

  await Product.updateOne(
    { _id: productId },
    { $inc: { stock: -1 } }
  );                                                     // BUG: no session

  await Payment.create([paymentDoc], { session });
});
// If the payment fails, the order is rolled back and the stock is NOT.

// Reads need it as well
const fresh = await Order.findById(orderId).session(session);
Notes
  • In mongosh the same rule applies in a different form: you must run your operations against session.getDatabase("name") rather than the global db, or they will not be part of the transaction.

Requirements, Costs and When to Use Them

Transactions need a replica set or a sharded cluster. A plain single-server mongod started on your laptop cannot run them, and attempting one produces an error saying so. Every Atlas cluster, including the free tier, is a replica set, which is the simplest way to try the examples above.

They also have a cost. A transaction holds resources on the server while it is open, and it is subject to a runtime limit — sixty seconds by default — after which it is aborted automatically. Long transactions increase the chance of conflicting with other work and being retried. The guidance that follows from this is short: keep them small, keep them fast, and keep everything that is not a database write outside them.

  • Use a transaction when two or more documents must change together and cannot be merged into one
  • Typical cases: money transfers, order placement with stock adjustment, deleting a record and its dependants together
  • Do not use one to make a single-document update safe — it already is
  • Do not put HTTP calls, email sends, payment gateway calls or file writes inside a transaction block
  • Keep the block short; a transaction that runs past the server's limit is aborted
  • Always handle the failure path, and tell the user plainly that nothing was changed
Notes
  • If a workflow spans MongoDB and an external system — charging a card and then recording the payment — no database transaction can cover both. The usual pattern is to record your intention first, perform the external call, then record the outcome, so a crash leaves a record you can reconcile rather than a silent gap.
Ask AI