Filter concepts by levelShowing all levels.

MongoDB · Section 22

Atomicity and Concurrency

Level
advanced
Read
30 min
Concepts
6

What single-document write atomicity already guarantees for free, using atomic update operators instead of a racy read-modify-write sequence, the optimistic concurrency pattern of staking a write on an expected current value, version fields as a general-purpose compare-and-set mechanism, how stale reads turn into real race conditions, and unique indexes as the only mechanism that fully closes a check-then-insert race.

MongoDB overview

What is true here

  1. A single write call to one document is atomic — but a read followed by a separate, later write is not one atomic operation.
  2. When the new value is purely a function of the current one, an atomic operator ($inc, $push, $max, …) closes the race a read-modify-write sequence would leave open.
  3. Optimistic concurrency puts the expected current value in the update's filter, so the check-and-write happens as one atomic step — retry with fresh data on a failed match.
  4. A version field, incremented on every write, catches any concurrent change more reliably than checking one business field directly — and closes stale-read races the same way.
  5. A unique index is the only way to fully close a check-then-insert race — an application-side existence check always has a window a concurrent writer can slip through.

What you will be able to do

  • Identify code with a read-then-write race and know when it matters
  • Replace a read-modify-write sequence with the matching atomic operator
  • Implement optimistic concurrency with a filter-based expected-value check and a correct retry
  • Use a version field as a general-purpose compare-and-set mechanism
  • Reach for a unique index, not an application-side check, to enforce a hard invariant

Atomicity fundamentals

What is already guaranteed for free, and the operator-based pattern for closing the read-modify-write gap.

Single-document writes are atomic

standardintermediate

This is the foundation the rest of this section builds on: a single write to a single document always applies completely or not at all, and no concurrent reader ever observes it half-done. Every concurrency pattern here — atomic operators, optimistic concurrency, compare-and-set — leans on this guarantee already existing.

Think of it as

Treat this as the one free concurrency primitive MongoDB gives every write, then ask what pattern gets you the rest of the way for anything that guarantee alone does not cover, like "read a value, then write based on what you read" — a sequence that atomicity alone does not protect.

text
// Given: one write call to one document is always atomic. Ask: does my operation actually fit inside one call?

What we're doing: Show that atomicity covers a single call but not a read-then-decide-then-write sequence.

javascript
// Atomic: one call, no read-then-decide gap
db.counters.updateOne({ _id: "views" }, { $inc: { count: 1 } })

// NOT atomic as a sequence, even though each step alone is:
const doc = await db.inventory.findOne({ _id: "sku-1" })
if (doc.stock > 0) {
  await db.inventory.updateOne({ _id: "sku-1" }, { $inc: { stock: -1 } })
}
2
A pure increment needs no prior read — one atomic call does the whole job.
5
Between the findOne and the updateOne, another concurrent request could also read stock > 0 and also decrement — both proceed thinking they safely checked, and stock can go negative.

Why this works: The read-check-write sequence is where single-document atomicity stops helping — it covers each individual call, but says nothing about what happens between two calls, which is exactly the gap the rest of this section's patterns exist to close.

Remember: A single write call to one document is always atomic — but a read, then a decision, then a separate write is not one atomic operation, however fast it looks in testing.

See also: single document atomicity · atomic operators over read modify write

Atomic operators instead of read-modify-write

coreintermediate

When the new value can be computed from the old one using $inc, $push, $addToSet, or similar, use that operator directly instead of reading the document, computing the new value in application code, and writing it back. The operator form is one atomic step; the read-then-write form is two, with a race condition in between.

Think of it as

Ask: "does the update only need the document's current value, or does it need outside information too?" If it is purely a function of the current value — add one, append an item, take the max — an update operator expresses it directly and atomically. Reach for read-modify-write only when the new value genuinely depends on something outside the document.

text
$inc: { field: <delta> } · $push: { arr: <value> } · $max: { field: <value> } · $currentDate: { field: true }

What we're doing: Replace a racy read-modify-write view counter with a single atomic $inc.

atomic-increment.jsjavascript
// Racy: two concurrent requests can both read the same starting value
const post = await db.posts.findOne({ _id: postId })
await db.posts.updateOne({ _id: postId }, { $set: { views: post.views + 1 } })

// Atomic: the server computes the new value from whatever the current one is
await db.posts.updateOne({ _id: postId }, { $inc: { views: 1 } })
2
Two concurrent requests can both read views: 41, both compute 42, and both write 42 — one increment is silently lost.
6
$inc has no read step to race on — the server applies the delta to whatever the current value is at the moment of the write, so concurrent increments never overwrite each other.

Why this works: The bug in the racy version is not visible in single-request testing — it only shows up under real concurrency, which is exactly why reaching for the atomic operator by default, rather than only after a bug report, is the safer habit.

Reading a value into application code just to add, subtract, or compare it, when an operator could do it directly

Wrong

javascript
const doc = await col.findOne({ _id }); await col.updateOne({ _id }, { $set: { score: Math.max(doc.score, newScore) } });

Better

javascript
await col.updateOne({ _id }, { $max: { score: newScore } });

What you see: Under concurrent writes, a higher score written by one request can be overwritten by a lower one still using a stale value it read earlier — the max operator has no such window.

Why: Any update that is purely a function of the document's current value has a matching atomic operator — reaching for a read first reintroduces exactly the race condition the operator exists to avoid.

Read-modify-write vs. an atomic operator

Read-modify-write

  • +findOne() to read current value
  • +compute new value in application code
  • +updateOne() to write it back
  • +race window between read and write

Atomic operator

  • updateOne() with $inc/$push/$max/…
  • one round trip, one atomic step
  • server computes the new value from the current one
  • no race window — nothing to race against
  • Read-modify-write
    • findOne() to read current value
    • compute new value in application code
    • updateOne() to write it back
    • race window between read and write
  • Atomic operator
    • updateOne() with $inc/$push/$max/…
    • one round trip, one atomic step
    • server computes the new value from the current one
    • no race window — nothing to race against

Remember: If the new value is purely a function of the current one, use the matching operator ($inc, $push, $max, …) directly — reading it into application code first reopens the race condition atomicity was supposed to close.

See also: single document writes are atomic · unique indexes and atomic invariants · update operators

Advertisement

Coordinating under concurrency

Optimistic concurrency and version fields for check-and-write, recognizing stale-read races, and unique indexes for hard invariants.

Optimistic concurrency

coreadvanced

Optimistic concurrency means proceeding as if no conflict will happen, then checking at write time — the update's filter includes the value you expect the document to currently hold, so the write only succeeds if nothing else changed it first. If it fails, you retry with fresh data instead of blocking.

Think of it as

Instead of locking a document before reading it ("pessimistic" — block everyone else out first), you read it freely and stake the write on a condition: "apply this change only if the document still looks like what I read." The single-document atomicity of the update itself is what makes that condition check-and-write into one atomic step, closing the race a plain read-then-write would have.

text
updateOne({ _id, <expectedField>: <expectedValue> }, { $set: {...} }) — check matchedCount, retry on 0

What we're doing: Update an order's status only if it is still in the status the code read it as, retrying on conflict.

optimistic-status-update.jsjavascript
async function markShipped(orderId) {
  const order = await orders.findOne({ _id: orderId })
  const result = await orders.updateOne(
    { _id: orderId, status: order.status },
    { $set: { status: "shipped" } }
  )
  if (result.matchedCount === 0) {
    // someone else changed status between our read and our write — retry with fresh data
    return markShipped(orderId)
  }
}
4
The filter includes status: order.status — the write only applies if the document still holds the exact status value that was just read.
7
matchedCount === 0 means the condition failed: another request already changed status first, so this write is discarded rather than silently overwriting that change.

Why this works: Wrapping the expected-current-value check into the update's own filter turns "check, then write" into one atomic operation — the single-document atomicity of the update itself is what prevents another write from sneaking in between the check and the write.

Reading a value, checking a condition in application code, then writing unconditionally

Wrong

javascript
const order = await orders.findOne({ _id }); if (order.status === "pending") { await orders.updateOne({ _id }, { $set: { status: "shipped" } }); }

Better

javascript
await orders.updateOne({ _id, status: "pending" }, { $set: { status: "shipped" } });

What you see: Two concurrent requests both read status: "pending", both pass the application-code check, and both write "shipped" — one overwrites the other's intended outcome without either request knowing.

Why: The application-code if-check happens after the read and before the write, which is exactly the unprotected gap — putting the same condition inside the update's filter instead makes the check and the write one atomic operation.

Optimistic concurrency: stake the write on the value you read
Client
MongoDB
  1. 1. read document, note version: 3
  2. 2. updateOne({ _id, version: 3 }, { $set: {...}, $inc: { version: 1 } })
  3. 3. matchedCount: 1 → success, version now 4
  4. 4. (if matchedCount: 0 → someone else updated first, retry)
  1. Client → MongoDB: read document, note version: 3
  2. Client → MongoDB: updateOne({ _id, version: 3 }, { $set: {...}, $inc: { version: 1 } })
  3. MongoDB → Client: matchedCount: 1 → success, version now 4
  4. Client → MongoDB: (if matchedCount: 0 → someone else updated first, retry)

Remember: Stake the write on the value you expect — put the expected current state in the update's filter, check whether it matched, and re-read before retrying on failure. That turns check-then-write into one atomic operation.

See also: version fields and compare and set · race conditions from stale reads

Version fields and compare-and-set

standardadvanced

A dedicated version field (an integer, incremented on every update) is a more reliable condition for optimistic concurrency than checking a business field directly — it changes on every write, even ones that would not otherwise look different, so no update slips through unnoticed.

Think of it as

Checking a business field like status works until two different updates both happen to leave status unchanged — a version counter has no such blind spot, because it increments on every single write regardless of which fields changed, making "has anything changed since I read this?" a precise, general-purpose question.

text
updateOne({ _id, version: v }, { $set: {...}, $inc: { version: 1 } })

What we're doing: Update a document's content field using a version-based compare-and-set, safe even when status itself does not change.

javascript
async function updateContent(docId, newContent) {
  const doc = await pages.findOne({ _id: docId })
  const result = await pages.updateOne(
    { _id: docId, version: doc.version },
    { $set: { content: newContent }, $inc: { version: 1 } }
  )
  if (result.matchedCount === 0) {
    return updateContent(docId, newContent) // stale version, retry with fresh read
  }
}
4
The filter checks version, not content itself — this catches any concurrent change since the read, even one that also touched content in a way a naive equality check on content would miss.
5
$inc bumps the version as part of the same atomic call that applies the change, so the next reader always sees a version strictly ahead of the one just consumed.

Why this works: A version field is a general-purpose "has this changed" signal that does not depend on knowing which specific field a conflicting update touched — checking a business field directly only works when you can be sure that field is the only one that matters.

Remember: A version field, incremented on every write and checked in the update filter, is the general-purpose compare-and-set pattern — more reliable than checking a business field directly, because it changes on every write, not just ones that touch that field.

See also: optimistic concurrency patterns · update operators

Race conditions from stale reads

standardadvanced

A stale read is a value read into application code that is no longer current by the time a decision based on it gets written back — the longer the gap between read and write, and the more concurrent writers there are, the more likely a decision ends up acting on outdated information.

Think of it as

Every read-then-decide-then-write sequence has an implicit assumption baked in: "nothing important changes between my read and my write." That assumption gets less safe the busier the document is and the more work happens in between — recognizing which code has this shape is the skill, since the race itself is invisible until two operations actually collide.

text
// Any code shaped like: read a value → decide something → write based on that decision, has a stale-read window to consider

What we're doing: Identify a stale-read race in a seat-booking flow and connect it to why it is intermittent.

javascript
// Stale-read race: two users can both pass the check for the same last seat
const event = await events.findOne({ _id: eventId })
if (event.seatsRemaining > 0) {
  // ...user fills out payment details, takes a few seconds...
  await events.updateOne({ _id: eventId }, { $inc: { seatsRemaining: -1 } })
}
2
seatsRemaining is read once, at the start of a flow that then waits on the user filling out payment details — a gap of real seconds, not milliseconds.
5
By the time this write runs, seatsRemaining could already be 0 from another user's booking that completed in between — this code has no way to know, because it never re-checks.

Why this works: The wider the gap between the read and the write — here, an entire payment form — the more likely something else changes in between, which is exactly why this class of bug is intermittent: it only shows up when two bookings genuinely overlap in time.

Remember: A stale read is not the bug — trusting it unconditionally at write time is. The fix is a conditional write (optimistic concurrency) or an atomic operator, never "hope the gap stays small".

See also: optimistic concurrency patterns · atomic operators over read modify write

Unique indexes and atomic invariants

standardadvanced

A unique index enforces "no two documents share this value" at the database level, atomically, for every insert regardless of race conditions — application-code checks like "look for an existing one first" cannot give the same guarantee, because the check and the insert are two separate steps that can race.

Think of it as

The database is the one place every concurrent writer necessarily passes through, so an invariant enforced there holds regardless of how many processes, servers, or retries are racing to insert at once. An application-code "check then insert" is only as safe as its narrowest race window, and that window is never actually zero.

text
db.collection.createIndex({ field: 1 }, { unique: true }) — then handle code 11000 as an expected outcome

What we're doing: Enforce "one username per account" safely under concurrent signups, using a unique index instead of a check-then-insert.

javascript
db.users.createIndex({ username: 1 }, { unique: true })

async function signUp(username) {
  try {
    await db.users.insertOne({ username, createdAt: new Date() })
  } catch (err) {
    if (err.code === 11000) {
      throw new Error("username already taken")
    }
    throw err
  }
}
1
The uniqueness guarantee lives in the database, so it holds no matter how many signup requests for the same username arrive at the same instant.
6
Code 11000 is the unique index doing its job — catching a race the request could not have detected on its own — and is handled as a normal, expected outcome.

Why this works: A check-then-insert in application code ("findOne for this username, insert if nothing found") has a race window between the two calls — two concurrent signups for the same username can both pass the check before either insert lands, and a unique index is the only mechanism that closes that window completely, because the database itself is the single point every insert has to pass through.

Remember: A unique index enforces an invariant atomically for every writer, everywhere — an application-code existence check always has a race window a unique index does not.

See also: unique indexes · unique null missing recap · atomic operators over read modify write

Advertisement