MongoDB Transaction Retry Re-Runs Your Side Effects

A wallet debit commits, a push notification goes out, and then Mongo rolls the whole thing back. The balance the user saw never existed. The code looks correct, and if the retry then succeeds your caller is handed a clean result with no error at all. A MongoDB transaction retry re-runs your entire callback, and anything in there that is not a database write does not roll back with it.
session.withTransaction() is usually described as a helper that retries transient errors for you. That is true, and it hides the part that matters: it retries by calling your function again.
What a MongoDB transaction retry actually retries
Here is the shape almost everyone writes.
await session.withTransaction(async () => {
const wallet = await Wallet.findOneAndUpdate(
{ userId },
{ $inc: { balance: -amount } },
{ session, new: true },
);
if (!wallet) throw new Error("wallet_not_found");
await Ledger.create([{ userId, amount, type: "DEBIT" }], { session });
// Not a database write. Not rolled back. Already gone.
await notifyUser(userId, { balance: wallet.balance });
});Two of those three operations are transactional. findOneAndUpdate and Ledger.create both carry the session, so an abort undoes them. notifyUser does not. It is an HTTP call, a socket emit, or a queue publish. Once it leaves the process, Mongo has no say in it.
Now make the commit fail. The driver aborts the transaction, calls your function a second time, and the second run sends a second notification. The first one described a balance that was rolled back.
Two error labels that look the same and need opposite handling
This is where it gets subtle. Open lib/sessions.js in mongodb@6.21.0 and the retry logic is two nested loops, not one. Which loop catches your error decides whether your callback runs again.
- •
TransientTransactionErrorthrown by your callback — the outer loop continues. The transaction aborts and your whole function runs again. - •
TransientTransactionErrorraised by the commit — the inner loop breaks out to the outer one. Same result: your function runs again. - •
UnknownTransactionCommitResult— the inner loop continues. OnlycommitTransaction()is retried. Your callback does not run again.
That last one flips the rule. A write conflict (error 112) inside a transaction carries the TransientTransactionError label, so the driver replays your callback and any side effect you queued is stale. Throw it away. But UnknownTransactionCommitResult means the commit may well have succeeded and only the acknowledgement was lost. The driver retries the commit alone. Discard your queued notification there, and when that retry commits, the money moves and nobody tells the user.
Two failures that both read as "the commit threw." One wants you to drop the side effect. The other wants you to keep it.
Defer the side effect until the commit resolves
The fix is to stop running side effects inside the callback at all. Queue them against the session, then flush them once the real commit returns.
There is no public "transaction committed" hook to hang that on. ClientSession extends an EventEmitter, but it only emits ended, and endSession() fires that for a commit and an abort alike — nothing on the event separates the two. So wrap the session instance's own commitTransaction.
const pendingBySession = new WeakMap();
// Always await this. The no-transaction branch runs the effect immediately,
// and an un-awaited rejection there takes the process down on modern Node.
async function deferUntilCommit(session, effect) {
// No transaction in play — nothing to wait for.
if (typeof session?.commitTransaction !== "function") {
await effect();
return;
}
if (!pendingBySession.has(session)) {
pendingBySession.set(session, []);
const originalCommit = session.commitTransaction.bind(session);
session.commitTransaction = async (...args) => {
let result;
try {
result = await originalCommit(...args);
} catch (error) {
// The commit may have succeeded and only the ack was lost. The driver
// retries the commit alone, so nothing will re-queue these. Keep them.
const mayHaveCommitted =
typeof error?.hasErrorLabel === "function" &&
error.hasErrorLabel("UnknownTransactionCommitResult");
// Anything else replays the callback, which queues fresh effects.
// The ones sitting here describe a rolled-back state. Drop them.
if (!mayHaveCommitted) pendingBySession.set(session, []);
throw error;
}
const pending = pendingBySession.get(session) || [];
// Reset rather than delete — see below.
pendingBySession.set(session, []);
await Promise.all(pending.map(async (run) => {
try {
await run();
} catch (error) {
console.warn("deferred effect failed", error);
}
}));
return result;
};
}
pendingBySession.get(session).push(effect);
}Two details in there matter more than they look.
- •The override goes on the session instance, never on
ClientSession.prototype. No other session or request is affected. - •The queue is reset to empty rather than deleted. That
WeakMapentry doubles as the "already wrapped" marker, and a session outlives a commit —endSession()is what retires it, notcommitTransaction(). Delete the entry and the next transaction on that same session wrapscommitTransactionagain, nesting a fresh wrapper every time.
Call sites barely change.
await session.withTransaction(async () => {
const wallet = await Wallet.findOneAndUpdate(
{ userId },
{ $inc: { balance: -amount } },
{ session, new: true },
);
if (!wallet) throw new Error("wallet_not_found");
await Ledger.create([{ userId, amount, type: "DEBIT" }], { session });
// Fires once, after the commit that actually stuck.
await deferUntilCommit(session, () =>
notifyUser(userId, { balance: wallet.balance }));
});Notice the flush swallows and logs a failed effect instead of rethrowing. By that point the database commit has already succeeded. A notification that failed to send is not a money-movement failure and must never be reported to the caller as one.
A write conflict where retrying is the wrong answer
Retry is the standard advice for a write conflict, and for an $inc that nudges a balance up or down it is usually right. For an operation that drains the whole wallet to zero it is wrong.
const session = await mongoose.startSession();
session.startTransaction();
try {
// ... move the entire balance out and write the ledger row
await session.commitTransaction();
} catch (error) {
await session.abortTransaction();
// A concurrent request already zeroed this wallet. Retrying loses the
// same race again, against a balance that is now nothing.
if (error?.code === 112 || error?.codeName === "WriteConflict") {
const empty = new Error("wallet_empty");
empty.status = 409;
throw empty;
}
throw error;
} finally {
session.endSession();
}Losing that race is not a transient failure. It is the answer. A 409 tells the caller exactly what happened; a retry loop hides it, burns the retry budget, and eventually surfaces something less useful.
Contention is often a query-shape problem before it is a transaction problem. A transaction that holds locks longer than it needs to creates conflicts that better indexes would have avoided. If yours cluster on one hot document, large `$in` arrays and the query planner covers the query side, and the broader MongoDB tuning checklist covers what to measure first.
The two-minute retry budget
One number worth having in your head: MAX_TIMEOUT inside withTransaction is 120000. The driver keeps retrying transient errors for up to two minutes before it gives up and throws.
On a contended document that is a request holding a connection for two minutes, while a client that timed out after thirty seconds has already retried the same operation against the same document. Pass timeoutMS on the session or in the transaction options and bound it to your API's own timeout. The driver's default is a ceiling, not a target.
The rule
If it does not carry the session, it is not in the transaction. Emails, queue publishes, socket pushes, calls to a payment provider — none of them roll back, and all of them can run more than once. Defer them past the commit or make them idempotent. Do neither and a user ends up staring at a balance that was never real.

