Field Note 25current
Track the version a client has seen for read-your-writes
TL;DR
Once state is replicated or spread across multiple backends — read replicas, derived stores fed asynchronously (ZFN-24Field Note · currentZFN-24 — One transactional store per write; propagate changes asynchronouslyCommit each logical write to exactly one transactional store; update other systems via reliable ordered async events — never a synchronous write across two stores, and never 2PC. With a relational primary the WAL is your replayable journal; write events into the same transaction.Open ZFN-24 →) — a client can write, then read from a backend that hasn’t caught up yet and not see its own change. If you need read-your-writes (read-after-write) consistency, you need a way to know the latest version of state a client has already observed, so a later read can be served from a backend that is at least that fresh (route to a caught-up replica, wait for it, or fall back to the primary).
Represent “what this client has seen” as a version token — a single log position/LSN for one replicated store, or a vector clock (a version per backend system) when several systems each have their own version. Carry it one of two ways: client-side, where the client holds the token (a cookie/header it presents on the next request), or server-side, where the ingress gateway tracks the session’s version and routes reads to backend instances that are caught up. Only pay for it on the reads that actually need the guarantee.
Context
Strong single-store consistency is easy to take for granted until you scale out reads or derive other systems from an event stream. Then the classic bug appears: a user saves a change, the write commits to the primary, the UI reloads, the read lands on a replica or a derived store that’s a few hundred milliseconds behind — and their change is “gone.” It comes back a moment later, which makes it intermittent and maddening. It’s not a write bug; it’s a read routed to a backend that hasn’t seen the write yet.
The fix isn’t to make everything synchronously consistent (you gave that up on purpose, for availability and to avoid 2PC — ZFN-24Field Note · currentZFN-24 — One transactional store per write; propagate changes asynchronouslyCommit each logical write to exactly one transactional store; update other systems via reliable ordered async events — never a synchronous write across two stores, and never 2PC. With a relational primary the WAL is your replayable journal; write events into the same transaction.Open ZFN-24 →). It’s to make reads aware of a freshness floor: the client knows it has already observed version N, so any read it does must come from a backend at version ≥ N. The only new requirement is a way to name the version a client has seen and to carry it from the write to the subsequent reads.
For a single replicated store, that version is one monotonic value (a log sequence number / commit position). When read-your-writes must span several independently-versioned systems, one number isn’t enough — you need a vector clock: a small map of system → version this client has seen, so each backend can be checked against its own component.
Recommendation
Name the version a client has seen, carry it, and serve reads from a backend at least that fresh — and only where it matters.
- Return a version token on writes. Every write response carries the version(s) it produced — an LSN for a single store, or a vector across the systems it touched.
- Require the token on reads that need read-your-writes. A read presents the token; the system guarantees it reads from a backend at ≥ that version by one of: routing to a replica known to be caught up, waiting briefly for the chosen replica to reach it, or falling back to the primary. Reads that don’t need the guarantee skip it and stay cheap/fast on any replica.
- Choose where the token lives:
- Client-side. The client holds the token — a cookie, a header, an opaque field echoed back — and presents it on subsequent requests. The client explicitly opts into the stronger semantics by carrying its position; the server stays stateless about sessions. (This is how consistency tokens in several distributed databases work.)
- Server-side. The ingress gateway keeps the vector clock for each session and routes that session’s reads to backend instances/replicas that satisfy it — read-your-writes as a routing decision in the data plane (ZFN-16Field Note · currentZFN-16 — Separate the data plane from the control planeSplit the serving path (data plane) from the management path (control plane). The data plane keeps serving on last-known-good config when the control plane is down — never call it on the hot path. Coupling them turns a control-plane bug into a serving outage.Open ZFN-16 →), transparent to the client. Costs session state and stickiness at the gateway, buys a clean client contract.
- Use a vector clock when, and only when, you span multiple versioned systems. One backend → one monotonic token is simpler; reserve the vector for genuine multi-system read-your-writes.
- Bound the cost. Routing/waiting for freshness adds latency and can push load to the primary; apply it to the specific reads that need it (right after a write, the user’s own view), not globally.
Scope. This buys read-your-writes / monotonic-reads for a client’s own session — not global linearizability. Two different users still see eventual consistency relative to each other, which is usually exactly what you want.
Consequences
Easier:
- The “I just saved it and it’s gone” bug disappears for the user who made the change, without forcing global strong consistency.
- You keep the availability and simplicity of replicas / async-derived stores (ZFN-24Field Note · currentZFN-24 — One transactional store per write; propagate changes asynchronouslyCommit each logical write to exactly one transactional store; update other systems via reliable ordered async events — never a synchronous write across two stores, and never 2PC. With a relational primary the WAL is your replayable journal; write events into the same transaction.Open ZFN-24 →) and add freshness only where it’s needed.
- The token makes “how stale is acceptable here?” an explicit, per-read decision instead of a hope.
Harder:
- Tokens must be threaded correctly end to end; a dropped token silently loses the guarantee, which is hard to notice in testing and shows up as the intermittent bug you were trying to kill.
- Server-side tracking adds session state and routing/stickiness at the gateway; client-side adds a contract clients must honor.
- Vector clocks across many systems get fiddly to produce, compare, and bound in size — keep the set of tracked systems small.
- Waiting-for-freshness adds tail latency, and primary fallback adds load; both need limits.
References
- ZFN-24Field Note · currentZFN-24 — One transactional store per write; propagate changes asynchronouslyCommit each logical write to exactly one transactional store; update other systems via reliable ordered async events — never a synchronous write across two stores, and never 2PC. With a relational primary the WAL is your replayable journal; write events into the same transaction.Open ZFN-24 → — async propagation to derived stores is what creates the read-your-writes gap this note closes.
- ZFN-16Field Note · currentZFN-16 — Separate the data plane from the control planeSplit the serving path (data plane) from the management path (control plane). The data plane keeps serving on last-known-good config when the control plane is down — never call it on the hot path. Coupling them turns a control-plane bug into a serving outage.Open ZFN-16 → — the gateway tracking session versions and routing reads is a data-plane responsibility.
- ZFN-12Field Note · currentZFN-12 — Queues, topics, and journals are different tools — don't conflate themQueues (competing consumers), topics (fan-out), and journals (ordered, replayable logs) give different guarantees. Don't conflate them; a pipeline often uses several. Prefer journals over topics, but not where head-of-line blocking hurts. With queues, bound the concurrency.Open ZFN-12 → — replicas and derived consumers lag the journal; the token is how a read knows it’s caught up.
- Read-your-writes / monotonic-reads session guarantees, and database consistency tokens, as worked designs.
Changelog
- 2026-06-12: First published as a Field Note.