MongoDB – Atomicity and Transactions

April 22, 20256 min readUpdated 8/24/2026

Coming from a relational database, the first question about MongoDB is usually “does it have transactions?” It does, since 4.0. But leading with that question gets the design backwards, because the more useful answer is that a well-modelled MongoDB application needs them far less often than you would expect — and that is a property of the document model, not a limitation you work around.

Single-document writes are already atomic

Every write to a single document is atomic, no matter how much of the document it touches. That includes nested fields, arrays, and several fields at once:

db.reels.updateOne(
  { _id: id },
  {
    $inc:  { "stats.views": 1, "stats.shares": 1 },
    $set:  { updatedAt: new Date() },
    $push: { recentViewers: viewerId }
  }
)

Four changes across three parts of the document, and no reader ever sees half of them. No transaction, no session, no extra cost.

That is why the embedding decision from lesson 6 is also a correctness decision, not just a performance one. Counters live inside the reel, so incrementing them is atomic. Put them in a separate collection and keeping the two consistent suddenly needs a transaction — the same data, a different schema, and a much harder concurrency problem.

Things that must change together want to live in the same document. Solve it at the schema level and the transaction question mostly disappears.

The concurrency mistake that has nothing to do with transactions

Most lost-update bugs in MongoDB are not missing transactions. They are read-modify-write:

// DON'T — two requests can read the same value and both write it back
const reel = db.reels.findOne({ _id: id })
db.reels.updateOne({ _id: id }, { $set: { "stats.views": reel.stats.views + 1 } })

A transaction would fix this, expensively. An update operator fixes it for free, because the arithmetic happens on the server inside the atomic write:

db.reels.updateOne({ _id: id }, { $inc: { "stats.views": 1 } })

Before reaching for a transaction, check whether an operator does the job. $inc, $push, $addToSet, $min, $max and findOneAndUpdate cover a surprising amount of what people write transactions for.

When you genuinely need one

Two or more documents that must change together, where you cannot merge them into one:

  • Moving value between two accounts — the canonical case, and the reason to think hard before choosing MongoDB for a ledger at all.
  • Creating a document and updating a counter in another collection, where a drifting count is not acceptable.
  • Writing to two collections that different services read independently.

Writing one

A transaction runs inside a session. The convenience API handles retries for you, which matters more than it looks:

const session = db.getMongo().startSession()

session.withTransaction(() => {
  const reels = session.getDatabase("reelcms").reels
  const audit = session.getDatabase("reelcms").audit

  reels.updateOne({ _id: id }, { $set: { status: "ARCHIVED" } }, { session })
  audit.insertOne({ action: "archive", reelId: id, at: new Date() }, { session })
})

session.endSession()

In Java, Spring’s @Transactional works once a MongoTransactionManager bean exists — without that bean the annotation is silently ignored, which is a particularly unpleasant way to discover the problem.

Every operation must be passed the session. An operation inside the block that does not receive it runs outside the transaction, commits independently, and is not rolled back. That is easy to do and produces exactly the inconsistency the transaction was meant to prevent.

What they cost

Transactions are not free, and the costs are worth naming:

  • A replica set is required. Standalone servers cannot run them — the reason lesson 2 insisted on a single-node replica set even on a laptop.
  • There is a 60-second default limit. Past transactionLifetimeLimitSeconds the transaction is aborted. A long batch job inside one transaction will not finish.
  • Conflicts abort, they do not queue. Two transactions touching the same document produce a TransientTransactionError for one of them; it must be retried whole. withTransaction does this automatically, which is why hand-rolling the session loop is a mistake.
  • They hold resources. Uncommitted writes pin oplog and cache. Long transactions on a busy cluster degrade everything else.

Design them to be short and to touch few documents. A transaction is a scalpel, not a container to put your business logic in.

The case that is genuinely awkward

Deleting a reel in ReelCMS touches three collections: the reel itself, its comments, and the collections that list it. MongoDB has no cascade, so the application does all three:

commentRepository.deleteByReelId(id);
collectionService.removeReelFromAll(id);
reelRepository.deleteById(id);

Three writes, no transaction. If the process dies between the first and the third, the reel survives while its comments are gone — an inconsistent state.

That is a real trade and worth naming rather than hiding. It is accepted here because the failure is recoverable and harmless: a reel with missing comments is a cosmetic problem, and the delete can simply be run again. Wrapping it in a transaction would be correct and would also make every delete pay for a session, on an operation that happens a few times a day.

The decision procedure is the useful part: ask what the inconsistent state actually costs. If the answer is “a wrong number on a dashboard until the next run”, skip the transaction. If it is “money exists in two places or neither”, do not.

Ordering helps more than people expect

Notice the order above: children first, parent last. If it fails partway, the reel is still there and still points at what remains, so retrying is straightforward. Reverse it — delete the reel first — and a failure leaves orphaned comments with no parent to find them from, which needs a sweep to clean up. Careful ordering turns many multi-document writes into something that is safely retryable without a transaction at all.

Write concern and read concern

Related, and more often the thing you actually needed. Write concern says how many members must acknowledge a write before it is considered done:

db.reels.insertOne(doc, { writeConcern: { w: "majority" } })   // safe from rollback
db.reels.insertOne(doc, { writeConcern: { w: 1 } })            // primary only — faster

w: "majority" is the default in recent versions and the right one: a write acknowledged only by a primary that then fails can be rolled back and disappear. w: 1 is a deliberate trade for throughput on data you can afford to lose — analytics events, for instance.

Read concern is the mirror image. "local" may return writes that have not yet replicated; "majority" returns only writes that cannot be rolled back. Reading your own recent write from a secondary with "local" is where “I just saved it and it is not there” comes from.

Sessions without transactions

A session is useful on its own, without a transaction wrapped around it. Inside one, MongoDB guarantees causal consistency: a read that follows a write in the same session sees that write, even if it is routed to a secondary that has not caught up.

const session = db.getMongo().startSession({ causalConsistency: true })
const reels = session.getDatabase("reelcms").reels

reels.updateOne({ _id: id }, { $set: { status: "PUBLISHED" } })
reels.findOne({ _id: id })     // guaranteed to see the update

That is the fix for the read-your-own-write problem from the replication lesson, and it is much cheaper than a transaction — no locks, no abort, no 60-second limit. Reach for it before reaching for a transaction; a surprising number of “we need a transaction” situations are really “we need this read to see that write”.

The short version

  • Single-document writes are atomic — including several operators at once.
  • Model so that things which change together live together, and the question mostly goes away.
  • Most lost updates are read-modify-write. Use $inc, not a transaction.
  • Transactions need a replica set, abort after 60 seconds, and abort on conflict rather than waiting.
  • Use withTransaction so retries are handled, and pass the session to every operation.
  • Keep them short and narrow.
  • Write and read concern often solve the durability problem people reach for transactions for.
  • A causally consistent session fixes read-your-own-write without a transaction.