Theo Zourzouvillys

Field Note 25 current

Track the version a client has seen for read-your-writes

By
Theo Zourzouvillys
Published
Tags
architecturedataconsistencyapi

TL;DR

Once state is replicated or spread across multiple backends — read replicas, derived stores fed asynchronously (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-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-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-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-24 — async propagation to derived stores is what creates the read-your-writes gap this note closes.
  • ZFN-16 — the gateway tracking session versions and routing reads is a data-plane responsibility.
  • 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.