A transaction lives inside a client session, the driver object that gives a series of operations a shared logical clock and a transaction number. Create one with client.startSession() and pass it to every operation in the transaction; an operation without the session option is not part of it, wherever it sits in your code — the classic bug here. Prefer withTransaction to the raw startTransaction/commitTransaction pair: it commits if your callback returns, aborts if it throws, and retries on the transient errors of Transaction Retries, so write a callback that can run twice.
// accounts holds A with 100 and B with 40; ledger has a unique index on ref
const session = client.startSession();
const bal = () => accounts.find().sort({ _id: 1 }).toArray();
const out = await session.withTransaction(async () => {
await accounts.updateOne({ _id: 'A' }, { $inc: { bal: -25 } }, { session });
await accounts.updateOne({ _id: 'B' }, { $inc: { bal: 25 } }, { session });
await ledger.insertOne({ ref: 'tx-1', from: 'A', to: 'B', amt: 25 }, { session });
return 'transfer done';
}, { readConcern: { level: 'snapshot' }, writeConcern: { w: 'majority' } });
console.log('commit ->', out, JSON.stringify(await bal()));
try { // the same ref: the unique index rejects it
await session.withTransaction(async () => {
await accounts.updateOne({ _id: 'A' }, { $inc: { bal: -10 } }, { session });
await ledger.insertOne({ ref: 'tx-1', from: 'A', to: 'B', amt: 10 }, { session });
});
} catch (err) { console.log('aborted ->', err.code, err.message.split('\n')[0]); }
console.log('after abort', JSON.stringify(await bal()), await ledger.countDocuments());
await session.endSession();commit -> transfer done [{"_id":"A","bal":75},{"_id":"B","bal":65}]
aborted -> 11000 E11000 duplicate key error collection: bank.ledger index: ref_1
dup key: { ref: "tx-1" }
after abort [{"_id":"A","bal":75},{"_id":"B","bal":65}] 1The second attempt debited A before it hit the duplicate key, and A is still at 75 afterwards: the debit was rolled back with the failed insert. The error you catch is the original driver error, so your catch sees 11000 and can map it to a 409 in Express 24,430 . Sessions are server-side records: create one per unit of work, end it in a finally, never share one across concurrent operations.