skip to content

Why does the MongoDB driver's withTransaction() helper exist instead of calling commitTransaction() yourself?

level: middleimportance: must knowfreq 60%

answer

  1. Think about what happens when a commit fails
  2. Two different failures, two different retry units
  3. The server labels the errors for you
  4. Your callback may run more than once
  5. Every operation must receive the session

basics

~20 s

MongoDB transactions can fail in two retryable ways: the whole transaction can abort transiently, or a commit can return an unknown outcome. withTransaction() runs your callback, commits, and applies both retry loops correctly, which hand-written code almost always gets wrong.

solid answer

~50 s

The raw API is `startSession()`, `startTransaction()`, your operations with the session passed to each one, then `commitTransaction()` or `abortTransaction()`. The hard part is the failure handling. MongoDB attaches error labels: **`TransientTransactionError`** means the transaction as a whole can be safely retried from the beginning — a write conflict, a stepdown, a network blip; **`UnknownTransactionCommitResult`** means the commit was sent but the outcome is unknown, and re-sending `commitTransaction()` is safe. `withTransaction()` implements exactly those two loops around a callback you supply, and gives up after roughly two minutes of retrying. The price is a contract on the callback: it may run more than once, so it must contain no external side effects, must pass the session to every operation, and must rethrow errors so the labels reach the helper. Anything not given the session simply runs outside the transaction and is never rolled back.

code

javascript · 13 lines
javascript
await session.withTransaction(async () => {
  await accounts.updateOne(
    { _id: from, balance: { $gte: 100 } },
    { $inc: { balance: -100 } },
    { session }
  );
  await accounts.updateOne(
    { _id: to },
    { $inc: { balance: 100 } },
    { session }
  );
  // no HTTP calls here: this body can run more than once
}, { readConcern: { level: "snapshot" }, writeConcern: { w: "majority" } });

go deeper

for a junior

Know the shape of the API: a session, a transaction started on it, operations that must each receive that session, then a commit. Recall that the helper handles retries for you.

for a middle

Explain the two error labels and that they have different retry units — whole transaction versus commit only — and why an operation without the session silently escapes the transaction.

for a senior

Demonstrate the callback contract in review: no outbound calls or non-idempotent side effects inside the block, errors rethrown so labels survive, and the transaction kept short enough to finish well inside the server's time limit.

for a principal

Own the pattern across services: decide where transactional boundaries sit, mandate the outbox approach for effects that must not repeat, and set expectations for retry budgets and observable abort rates under contention.

## The raw API A multi-document transaction lives inside a **client session** — a logical handle the driver uses to tie operations together and to attach a transaction number the server can recognise. The manual sequence is: ```javascript const session = client.startSession(); try { session.startTransaction({ readConcern: { level: "snapshot" }, writeConcern: { w: "majority" } }); await accounts.updateOne({_id: from}, {$inc: {balance: -100}}, { session }); await accounts.updateOne({_id: to}, {$inc: {balance: 100}}, { session }); await session.commitTransaction(); } catch (e) { await session.abortTransaction(); throw e; } finally { await session.endSession(); } ``` Every operation must be given the session. This is the single most common bug in hand-rolled code: an operation without `{ session }` executes as an ordinary write outside the transaction, is visible immediately, and is *not* undone when the transaction aborts. There is no error and no warning — the write simply is not part of the transaction. ## The two retryable failure classes What makes the manual version hard is not the happy path, it is the errors. The server tags errors with labels, and two of them are contracts with the driver: **`TransientTransactionError`** — the transaction cannot continue, but nothing it did is visible and the *whole* transaction can be re-run from the start. The usual causes are a write conflict with a concurrent transaction (MongoDB fails these fast rather than waiting), a primary stepdown or election, or a transaction that has already been aborted by the server. The correct response is: start a new transaction and run all the work again. **`UnknownTransactionCommitResult`** — you sent `commitTransaction()` and did not learn the outcome, typically a network error or a `maxCommitTimeMS` timeout. The transaction may or may not have committed. Re-sending `commitTransaction()` on the same session is safe: if it committed, the server reports success again; if it did not, the commit is attempted. The correct response is to retry the *commit only*, not the whole callback. Getting this right by hand means two nested loops with different retry units and careful label inspection. Most hand-written implementations either retry nothing (turning ordinary elections into user-visible errors) or retry everything (re-running work after a commit that actually succeeded). ## What withTransaction() does `session.withTransaction(callback, options)` wraps all of it: it starts the transaction, runs your callback, commits, retries the callback on `TransientTransactionError`, retries the commit on `UnknownTransactionCommitResult`, and aborts on any error it cannot retry. It stops retrying after a limit of about 120 seconds so a permanently conflicting workload fails rather than spinning forever. Transaction options — read concern, write concern, read preference, `maxCommitTimeMS` — are passed to the helper rather than to `startTransaction()`. ## The contract on your callback Because the callback can run several times, it must be written as re-runnable work: - **No external side effects.** Sending an email, charging a card, publishing to a queue or mutating shared in-memory state inside the callback means doing it once per attempt. Collect the intent inside the transaction (an outbox document, say) and perform the effect after the helper returns. - **Pass the session to every operation**, including reads you rely on. - **Do not swallow errors.** A `try/catch` inside the callback that logs and returns hides the label and defeats the retry. - **Do not commit or abort inside the callback** unless you deliberately want to end the retry loop. - **Keep it short.** Every attempt runs against the server's transaction time limit, and long callbacks hold a snapshot and locks the whole time. ## Other rules worth knowing Reads in a transaction are served from the primary — read preference must be primary, and requesting a secondary is an error. The transaction's read concern and write concern are set once for the transaction and apply to the whole thing, not per operation. MongoDB 4.4 and later allow creating a collection or an index inside a transaction, with restrictions; other catalog operations remain off-limits. Operations against the `admin`, `config` and `local` databases are not transactional. ## What an interviewer is listening for The weak answer is "it's just convenience." The strong answer names the two error labels, says what the retry *unit* is for each, and states the callback contract — because that contract is where real systems break: the retry silently doubles an outbound call, or an operation missing its session escapes the transaction entirely.

  • What is the difference in retry unit between TransientTransactionError and UnknownTransactionCommitResult?
    `TransientTransactionError` means nothing the transaction did is visible, so the retry unit is the entire transaction: start a fresh one and re-run all the work. `UnknownTransactionCommitResult` means the commit may already have succeeded, so re-running the work would duplicate it; the retry unit is just `commitTransaction()` on the same session, which the server treats idempotently for that transaction number.
  • Your callback publishes a message to a queue after the writes. What goes wrong and how do you fix it?
    The helper can re-run the callback after a transient error, so the message is published once per attempt, and it can also be published for a transaction that ultimately aborts. Write an outbox document inside the transaction instead, then have a separate process — or code after `withTransaction()` returns — read the outbox and publish. The effect then happens at most once per committed transaction.
  • What happens to an operation inside the transaction block that is not given the session?
    It executes as an ordinary, non-transactional write. It becomes visible to other clients immediately, it is not covered by the transaction's read snapshot, and aborting the transaction does not undo it. There is no error, which is why the bug survives review — it only shows up as inconsistent data after a rollback or a retry.

saying these in an interview costs you the question

  • Calls withTransaction() mere syntactic sugar with no retry semantics
  • Puts an HTTP call or email send inside the callback
  • Forgets to pass the session, so writes escape the transaction
  • Says retrying a commit risks applying the transaction twice
  • Catches and swallows errors inside the callback, defeating retries

context