---
title: 'Read and Write Concerns'
source: 'https://academia.sh/en/courses/nosql/read-and-write-concerns'
course: 'Non-Relational Data Models'
language: en
updated: '2026-08-23T07:00:46+00:00'
license: 'CC BY-SA 4.0'
---

# Read and Write Concerns

Measuring the acknowledgement level as a setting: running the same loan scenario under three write concerns and counting ack turns, acknowledged writes, and the number of writes lost at the moment of failure, separating rolled-back writes from lost writes, showing how requiring acknowledgement from every member turns the loss of a single member into a write outage, and comparing unanswered requests, stale reads, and the number of reads that see data still to be rolled back across four combinations of read concern and read direction.

The previous two lessons left the same gap open. In the replica set, accepted loan records were
rolled back after a failure, and how to prevent that was never said; the sharding lesson noted
that every shard is its own replica set but left open how many members a write has to reach to
count as delivered. These two questions are two faces of a single setting.

The setting is called the **write concern**: how many members have to have applied a write before
it is reported successful to the caller. Its counterpart on the read side is the **read
concern**: what verification level a read is answered with. Both are settings that can be given
per collection or per request; neither is a fixed property of the store or the cluster.

## Two Separate Settings

The number carried by the write concern is called the **acknowledgement level**. `w=1` requires
only the primary to apply the write; `w=majority` requires more than half the membership to apply
it; `w=all` requires every member. As the acknowledgement level rises, the caller waits longer,
because the acknowledgement comes back not from the nearest member but from the **farthest**
member among the required count.

Two settings of the read concern are measured here. A `local` read returns the value held by the
selected member without verification. A `majority` read returns only data that has reached a
majority — that is, data that cannot be rolled back. A third setting exists independently of the
read concern and must not be confused with it: the **read direction** determines whether a
request goes to the primary or to a secondary. The two are chosen separately and produce separate
columns in the measurement.

## The Mechanism

The mechanism below is again a **model**: no real cluster is built, a turn is an abstract step,
and member distance is a parameter in turns. An acknowledgement is the primary **learning** that
the required number of members have applied the write; its cost is therefore not a single leg but
a round trip.

**NS19 — member distance from the primary is 3, 5, 6, and 8 turns.** Reason: some of the
secondaries are in the same building, some in a distant site. **NS20 — one loan record is written
and one read arrives each turn.** Reason: write and read rates are assumed equal; if the ratio
changes, the stale-read count changes linearly. The failure timetable is the same as the first
lesson's: the primary stops, detection takes two turns, election two turns.

```js
// concern/model.mjs — the acknowledgement level is a MODEL. A turn is an abstract step; the
// per-member delay, the failure's turn, and the election duration are parameters in turns, not
// measured time. No real cluster is built. A write is one loan record per turn; an ack is the
// primary learning that w members have applied the write (round trip, i.e. twice the delay).
export const DELAY = [0, 3, 5, 6, 8];   // distance to the primary, per member (turns)
export const MEMBERS = 5, FAILURE = 21, DETECTION = 2, ELECTION = 2, TURNS = 48;
const MAJORITY = Math.floor(MEMBERS / 2) + 1;

export function run({ w, read = "local", direction = "primary" }) {
  const down = new Set();
  const log = [0], applied = Array(MEMBERS).fill(0), writes = [], reads = [];
  let primary = 0, seq = 0, wait = 0, cutoff = null, handover = null;

  // standing secondaries' distance from the primary, smallest to largest
  const distance = () => [...Array(MEMBERS).keys()]
    .filter((i) => i !== primary && !down.has(i))
    .map((i) => Math.abs(DELAY[i] - DELAY[primary])).sort((a, b) => a - b);
  const ackTime = (k) => {
    if (k <= 1) return 0;
    const u = distance();
    return u.length >= k - 1 ? 2 * u[k - 2] : null;   // without enough members, the ack never arrives
  };
  // the largest seq acknowledged and not rolled back as of t: the threshold staleness is measured against
  const lastAcked = (t) => writes.reduce((m, y) =>
    (y.ack !== null && y.ack <= t && y.rolledBack === false && y.seq > m ? y.seq : m), 0);

  for (let t = 1; t <= TURNS; t += 1) {
    if (t === FAILURE) { down.add(primary); primary = null; }

    if (primary === null) {
      wait += 1;
      const voters = [...Array(MEMBERS).keys()].filter((i) => !down.has(i));
      if (voters.length >= MAJORITY && wait === DETECTION + ELECTION) {
        primary = voters.reduce((a, b) => (applied[b] > applied[a] ? b : a));
        cutoff = applied[primary];
        seq = cutoff; handover = t;
        for (let x = 0; x < log.length; x += 1) log[x] = Math.min(log[x], cutoff);
        for (const y of writes) if (y.seq > cutoff) y.rolledBack = true;   // the log is truncated
      }
    } else {
      seq += 1;
      log[t] = seq;
      const s = ackTime(w);
      writes.push({ seq, arrival: t, ack: s === null ? null : t + s, rolledBack: false });
      for (let i = 0; i < MEMBERS; i += 1) {
        if (down.has(i)) continue;
        applied[i] = i === primary ? seq
          : Math.max(applied[i], log[t - Math.abs(DELAY[i] - DELAY[primary])] ?? 0);
      }
    }
    // an ack not completed before the primary died never arrives
    if (t === FAILURE) for (const y of writes) if (y.ack !== null && y.ack >= t) y.ack = null;

    const secondary = [...Array(MEMBERS).keys()].filter((i) => i !== primary && !down.has(i));
    const m = direction === "primary" ? primary : (secondary.length ? secondary[t % secondary.length] : null);
    if (m === null) { reads.push({ t, noResponse: true }); continue; }  // no response while there is no primary
    const sc = ackTime(MAJORITY);
    const committed = sc === null ? lastAcked(t) : Math.min(lastAcked(t), log[t - sc] ?? 0);
    const value = read === "local" ? applied[m] : Math.min(applied[m], committed);
    reads.push({ t, member: m, value, actual: lastAcked(t), noResponse: false });
  }

  const acked = writes.filter((y) => y.ack !== null && y.ack <= TURNS);
  const answered = reads.filter((o) => o.noResponse === false);
  return {
    ackTurns: writes[0].ack === null ? null : writes[0].ack - writes[0].arrival,
    acked: acked.length, unacked: writes.length - acked.length,
    rolledBack: writes.filter((y) => y.rolledBack).length,
    lost: acked.filter((y) => y.rolledBack).length,
    afterHandover: acked.filter((y) => y.arrival > handover).length,
    noResponse: reads.filter((o) => o.noResponse).length,
    stale: answered.filter((o) => o.value < o.actual).length,
    staleness: answered.reduce((a, o) => a + (o.actual - o.value), 0) / answered.length,
    // reads that see data still to be rolled back: only pre-failure reads are counted, because
    // after the truncation the same seq numbers are reused validly
    phantom: answered.filter((o) => o.t < FAILURE && cutoff !== null && o.value > cutoff).length,
    reads: answered.length, handover, cutoff,
  };
}
```

```js
// concern/measure.mjs — same loan scenario in three write concerns, then four read configurations
import { run, MEMBERS, FAILURE, TURNS, DELAY } from "./model.mjs";

const s = (x, n) => String(x).padStart(n);
const LABEL = { 1: "w=1", 3: "w=majority", 5: "w=all" };
console.log(`${MEMBERS}-member cluster, member distance ${DELAY.join("/")} turns. ${TURNS} turns, one`);
console.log(`loan record per turn. The primary stops at turn ${FAILURE}; detection 2, election 2 turns.\n`);
console.log("write concern | ack turns | acked | unacked | rolled back | LOST WRITES | acked after handover");
console.log("--------------|-----------|-------|---------|-------------|-------------|---------------------");
for (const w of [1, 3, 5]) {
  const r = run({ w });
  console.log(`${LABEL[w].padEnd(13)} | ${s(r.ackTurns === null ? "none" : r.ackTurns, 9)} | ${s(r.acked, 5)} | ` +
    `${s(r.unacked, 7)} | ${s(r.rolledBack, 11)} | ${s(r.lost, 11)} | ${s(r.afterHandover, 21)}`);
}

console.log("\nread side (write concern fixed at w=1; truncation boundary: seq " + run({ w: 1 }).cutoff + "):");
console.log("read direction | read concern | no response | stale reads | average staleness | saw to-be-rolled-back data");
console.log("---------------|--------------|-------------|-------------|-------------------|---------------------------");
for (const direction of ["primary", "secondary"]) {
  for (const read of ["local", "majority"]) {
    const r = run({ w: 1, read, direction });
    console.log(`${direction.padEnd(14)} | ${read.padEnd(12)} | ${s(r.noResponse, 11)} | ` +
      `${s(`${r.stale}/${r.reads}`, 11)} | ${s(r.staleness.toFixed(2), 18)} | ${s(r.phantom, 26)}`);
  }
}

const u = DELAY.slice(1).sort((a, b) => a - b);
console.log("\nindependent of the run: ack turns is twice the (w-1)th value in the secondaries'");
console.log(`distance ranking. In this cluster the ranking is ${u.join(" < ")}, so as w grows,`);
console.log(`ack turns takes the values ${u.map((x) => 2 * x).join(", ")}; it never decreases for any w.`);
```

```
5-member cluster, member distance 0/3/5/6/8 turns. 48 turns, one
loan record per turn. The primary stops at turn 21; detection 2, election 2 turns.

write concern | ack turns | acked | unacked | rolled back | LOST WRITES | acked after handover
--------------|-----------|-------|---------|-------------|-------------|---------------------
w=1           |         0 |    44 |       0 |           3 |           3 |                    24
w=majority    |        10 |    28 |      16 |           3 |           0 |                    18
w=all         |        16 |     4 |      40 |           3 |           0 |                     0

read side (write concern fixed at w=1; truncation boundary: seq 17):
read direction | read concern | no response | stale reads | average staleness | saw to-be-rolled-back data
---------------|--------------|-------------|-------------|-------------------|---------------------------
primary        | local        |           3 |        0/45 |               0.00 |                          3
primary        | majority     |           3 |       44/45 |               7.82 |                          0
secondary      | local        |           0 |       48/48 |               4.06 |                          0
secondary      | majority     |           0 |       48/48 |               8.69 |                          0

independent of the run: ack turns is twice the (w-1)th value in the secondaries'
distance ranking. In this cluster the ranking is 3 < 5 < 6 < 8, so as w grows,
ack turns takes the values 6, 10, 12, 16; it never decreases for any w.
```

## A Lost Write Is Not the Same as a Rolled-Back Write

Two columns of the first table look alike and are not the same thing. The rolled-back write count
is 3 under all three settings; this is a fact about the cluster and does not change with the
setting, because there are three records the old primary wrote that never reached any surviving
member. The lost write count is the subset of those that had already been reported successful to
the caller, and it changes directly with the setting: 3 under `w=1`, 0 under `w=majority` and
`w=all`.

That `w=majority` zeroes out the lost-write count is not a coincidence; it is a direct consequence
of the previous lesson's election rule. If a write has reached a majority, every member eligible
to become the new primary comes from within that majority; because a candidate cannot be behind
the majority that voted for it, that write is also present on the new primary. The write concern
and the election rule are two ends of the same number.

The cost sits in the ack-turns column: 0 turns against 10. This number does not depend on the run;
the line in the final block derives it. Ack turns is twice the `(w-1)`th value in the secondaries'
distance ranking. Because the ranking in this cluster is 3, 5, 6, 8, ack turns takes the values 6,
10, 12, 16 as w grows, and it never decreases at any step. Raising the acknowledgement level does
not just slow writes down — it translates directly into which member is slow becoming how long
the caller waits.

## The Cost of Requiring Acknowledgement From Everyone

The `w=all` row gives the harshest result. Before the failure, only 4 of the 20 writes were
acknowledged; after the failure, none were. The reason is arithmetic: while one member is down,
the "every member" condition can never be satisfied. In a five-member cluster, the loss of a
single member stops writes entirely under `w=all`.

This row shows that the acknowledgement level cannot be read as "higher is better." `w=all` zeroes
out lost writes, but it buys that with 40 unacknowledged writes and zero throughput after the
failure. `w=majority` achieves the same zero loss with 16 unacknowledged writes and 18
acknowledged writes after the failure. The majority rule is the peak here too: raising the
acknowledgement level above the majority adds no durability, only fragility.

Writes left unacknowledged are not data loss; they are writes the caller does not know the outcome
of. A loan record may or may not have posted to the member's account. Being retriable requires
that the loan operation itself be designed to be idempotent.

## Read Direction and Read Concern

The second table compares all four combinations in the same scenario, and each of the four pays a
different cost.

`primary` + `local`: none of the 45 responses is stale, average staleness 0.00. Against that, 3
reads saw data that was later rolled back, and 3 requests went unanswered during the outage
window. This setting gives the most current answer and carries the greatest risk of being wrong: a
record showing on the loan screen can turn out never to have existed a few turns later.

`primary` + `majority`: reads that see data still to be rolled back drop to 0. The cost sits in
the stale-reads column — 44 of the 45 responses are stale, average staleness 7.82 loan records. A
majority read does not give the "correct" value; it gives the value that will not be rolled back,
and it waits for the majority to catch up to get it.

The shared gain of the two `secondary`-direction rows is in the no-response column: 0. A request
reading from a secondary gets an answer even while the primary is down and an election is running.
This is the right setting for library catalog searches. The cost in both rows is staleness: 4.06
records with `local`, 8.69 with `majority`. Combining the secondary direction with a majority read
stacks two sources of delay on top of each other — the member's own lag plus the majority's commit
delay.

The rule that follows is to configure per operation. The loan-writing operation is written with
`w=majority`, and the majority is awaited before the member is told "the book is yours"; the same
record's reflection in the catalog is read with `secondary` + `local`, accepting a staleness of
four records.

## Summary

- Write concern and read concern are settings given per request; the acknowledgement level is the
  number of members required for a write to count as successful.
- The rolled-back write count came out at 3 under all three settings — this is a fact about the
  cluster; the lost-write count changed with the setting: 3 under `w=1`, 0 under `w=majority` and
  `w=all`.
- Ack turns is derived independently of the run: twice the `(w-1)`th value in the secondaries'
  distance ranking — 6, 10, 12, 16 in this cluster, and it never decreases at any step.
- Under `w=all`, the loss of a single member stopped writes entirely: acknowledged writes after
  the failure, 0; total unacknowledged, 40. Going above the majority added no durability.
- The four combinations of read settings paid four separate costs: `primary`+`local`, 3 unanswered
  requests and 3 reads that saw data still to be rolled back; `primary`+`majority`, 44/45 stale and
  7.82 staleness; the `secondary` direction, 0 unanswered but 48/48 stale.
- Read direction and read concern are separate settings; combining them stacks two sources of
  delay (4.06 against 8.69 staleness).

## Next Step

There is something this lesson's tables leave unnamed. The guarantee given by `w=majority`
together with a `majority` read does not carry the same name as the guarantee given by `w=1` with
a `local` read; there are combinations standing between the two as well, and each of them promises
something different. The names for these promises were defined in the Introduction to System
Design course: strong consistency, eventual consistency, monotonic reads, and read-your-writes.
The definitions stay there; what is missing here is which combination of settings delivers which
promise. The next lesson builds that mapping, adds a model not on that list — causal consistency —
and runs the same member session under six combinations of settings, counting the number of
read-your-writes failures, monotonic violations, reads where the causal link breaks, and the wait
turns that make up the cost of each guarantee.
