Skip to main content
Distributed Systems11 min read

Read-Your-Writes Is a Session Contract, Not a Database Setting

I added a read replica and within a day users reported edits that "didn't save" — they had saved, but the reads raced the replication stream and lost. These are my notes on why read-your-writes is a session contract rather than a replica setting, reproduced with a pinned 50 ms lag in Go, and the three ways to carry it — sticky routing, a GTID-style version token, and a bounded-staleness wait — compared on cost.

All Posts
2/4

The first time it bit me, I had just added a read replica to take pressure off a primary that was getting hammered on reads. The change looked free. Within a day, a handful of users reported that their profile edits "didn't save" — except the edits were saved. They just couldn't see them on the next page load.

The code was the kind every backend engineer has written:

python
id = create_resource(...)
get_resource_state(id, ...)   # sometimes: "id does not exist"

The write went to the primary. The read, being read-only, got routed to a replica. The read raced the replication stream and lost. For a few tens of milliseconds, the system insisted the thing I had just created did not exist. Marc Brooker opens his November 2025 post on strong consistency with this exact shape, and the detail that stuck with me is how application code "fixes" it: a retry loop with a sleep and a magic number, which turns into an infinite loop the moment the resource can also be legitimately absent.

I spent a while looking for the database setting that would make this go away. That was the wrong frame. There is no knob on the replica that fixes this, because the property I wanted is not a property of the replica. It is a property of the session — my session, the one that did the write and then did the read.

What read-your-writes actually means

Read-your-writes is one of four session guarantees named in the Bayou work by Terry and colleagues back in 1994. The four are read-your-writes, monotonic reads, writes-follow-reads, and monotonic writes. The framing that made it click for me: each guarantee is about presenting one session with a view of the data that is consistent with that session's own actions, even when the reads and writes land on different, mutually inconsistent servers.

Read-your-writes says: within a session, a read reflects every write that session already did. Nothing about other sessions. Nothing about global order. Just the promise that I do not see time move backwards relative to my own writes.

That word — session — is the whole point. The guarantee lives between the client and the data, not inside any single replica. So it has to be carried somewhere: in how requests get routed, or in a token the client holds, or in a freshness check the replica runs before it answers. Three places, three implementations, three different bills. The rest of this post is about reproducing the failure, then paying each of those bills and seeing which one hurts least.

Reproducing the stale read on purpose

I find consistency bugs impossible to reason about until I can make them happen on demand. Real replication lag is the enemy of that — it varies, so the bug is a heisenbug. So I pinned the lag. The program below is a primary and a replica in one Go file. The primary holds a monotonic version counter, the in-memory stand-in for MySQL's gtid_executed. Every write bumps the counter, ships the change down a channel, and hands the caller back the version as a token. The replica applies that stream after a fixed 50 ms delay.

go
package main

import (
	"fmt"
	"sync"
	"time"
)

// write carries the version token a client keeps to enforce read-your-writes.
type write struct {
	version int64
	key     string
	value   string
}

// primary is the source of truth plus a monotonic version counter,
// the moral equivalent of MySQL's gtid_executed.
type primary struct {
	mu      sync.Mutex
	version int64
	data    map[string]string
	stream  chan write
}

func newPrimary() *primary {
	return &primary{data: map[string]string{}, stream: make(chan write, 64)}
}

// Write applies locally, ships the change to the replica, and returns
// the token the caller must carry to read its own write.
func (p *primary) Write(key, value string) int64 {
	p.mu.Lock()
	p.version++
	v := p.version
	p.data[key] = value
	p.mu.Unlock()
	p.stream <- write{v, key, value}
	return v
}

// replica applies the stream after a fixed lag and tracks how far it has caught up.
type replica struct {
	mu      sync.Mutex
	applied int64
	data    map[string]string
	caught  *sync.Cond
}

func newReplica(p *primary, lag time.Duration) *replica {
	r := &replica{data: map[string]string{}}
	r.caught = sync.NewCond(&r.mu)
	go func() {
		for w := range p.stream {
			time.Sleep(lag) // replication lag
			r.mu.Lock()
			r.data[w.key] = w.value
			r.applied = w.version
			r.caught.Broadcast()
			r.mu.Unlock()
		}
	}()
	return r
}

// Read is the eventually-consistent path: whatever the replica holds right now.
func (r *replica) Read(key string) string {
	r.mu.Lock()
	defer r.mu.Unlock()
	return r.data[key]
}

// ReadYourWrites blocks until the replica has applied the caller's token,
// the in-memory analogue of WAIT_FOR_EXECUTED_GTID_SET.
func (r *replica) ReadYourWrites(key string, token int64) string {
	r.mu.Lock()
	defer r.mu.Unlock()
	for r.applied < token {
		r.caught.Wait()
	}
	return r.data[key]
}

func main() {
	p := newPrimary()
	r := newReplica(p, 50*time.Millisecond)

	token := p.Write("profile:42", "name=Tiare")

	// Naive: read the replica immediately after the write.
	fmt.Printf("stale read: %q\n", r.Read("profile:42"))

	// Session contract: carry the token, wait for it, then read.
	fmt.Printf("fresh read: %q\n", r.ReadYourWrites("profile:42", token))
}

Run it with:

go run main.go

Every run prints the same two lines:

stale read: "" fresh read: "name=Tiare"

The first read happens microseconds after the write returns, while the replica is still 50 ms from applying it, so it sees an empty string — the "does not exist" from production, reproduced deterministically. The second read is the contract in its smallest possible form. The non-obvious line is the for r.applied < token loop in ReadYourWrites: the read does not ask "do you have my key," it asks "have you caught up to version 7 yet," and blocks on a condition variable until the answer is yes. That single comparison is the entire idea. The client carried a token; the replica honored it.

The diagram below traces the race on a timeline so the ordering is unambiguous: the write commits on the primary at v=7, the naive read hits the replica while it is still at v=6 and returns stale, and the token-carrying read parks until the replica reaches v=7.

The same contract, three ways to carry it, three bills

The Go file implements the token approach because it is the one that generalizes. But it is not the only way, and it is not always the right one. Here is how the three line up on what they actually cost.

Sticky-to-primary window. After a session writes, route that session's reads to the primary for a fixed window — a few seconds, say. Simple to reason about, no token threading, and the read is never stale because it is reading the source of truth. The bill: every "recently wrote" session is now load on the primary, which is the exact load you added the replica to shed. And the window is a guess. Set it shorter than your worst-case lag and the race comes back; set it longer and you are paying primary cost for reads that would have been safe on a replica.

Version token (GTID or LSN). The write returns a position — a MySQL GTID, a Postgres LSN, or the counter in the program above. The client carries it and the read waits for the replica to reach it. This is the most faithful implementation of the contract and the only one that survives complex routing, because the token travels with the request rather than depending on where it lands. The bill comes in two parts. First, tail latency: a read can now block for up to the replication lag, so your read p99 inherits your replication p99. Second, and this is the one that hurt in practice, you have to thread the token through every hop. The moment a service forgets to forward it — a cache layer, a second microservice, a background job — the contract silently degrades to a normal stale read with no error to tell you it happened.

Bounded-staleness wait. The replica refuses to answer reads older than some threshold, or blocks until it is fresh enough. It bounds how wrong an answer can be without per-session bookkeeping. The bill: it is not actually read-your-writes. It is "read something no more than X stale," which is a different and weaker promise. Under a lag spike it either rejects reads or serves data right at the staleness edge, and neither of those is "you will see your own write."

approachwhere the contract livesmain costquiet failure
sticky-to-primaryrequest routingprimary load you tried to shedwindow shorter than worst-case lag
version tokena token the client carriesread p99 inherits replication p99a hop drops the token, no error
bounded stalenessreplica freshness checkrejects or serves at the edge under lagweaker than read-your-writes

What the real databases hand you

The token approach is not something you build from scratch on MySQL. Since 5.7.5, MySQL ships WAIT_FOR_EXECUTED_GTID_SET(gtid_set [, timeout]). After a write on the primary, you read the transaction's GTID from @@global.gtid_executed, carry it to the replica, and call the function before your read. It blocks until that GTID set is a subset of the replica's executed set, then returns 0; on timeout it returns 1. That return value matters: it is the seam where you decide to fall back to the primary, raise an error, or serve stale on purpose. The for loop and condition variable in my Go file are exactly this function with the network removed.

Aurora MySQL exposes the same idea as a session variable. Set aurora_replica_read_consistency to SESSION and reads through write forwarding wait for that session's forwarded writes to replicate before answering; the other values are EVENTUAL and GLOBAL. AWS notes that reader lag is usually well under 100 ms after a write, which tells you the size of the tail-latency bill the token approach signs you up for — single-digit-to-double-digit milliseconds of occasional blocking, not seconds.

Where this stops being enough

I want to be honest about the ceiling here, because I hit it. Read-your-writes per replica does not give you monotonic reads across replicas. Picture two reads in the same session: the first lands on a caught-up replica and sees your write; the second lands on a laggier replica and does not. You read your write, then un-read it. The set of writes you observe went backwards. Brooker makes this point precisely — the standard read-replica pattern does not offer monotonic reads, so the tip "comes and goes" as requests bounce between replicas. Fixing that means pinning a session to one replica or pushing the high-water mark forward monotonically, which is more bookkeeping on top of the token you are already carrying.

There is a second crack. The whole scheme assumes "your writes" is a well-defined set the client can name with a token. For a single profile edit, it is. For infrastructure-as-code, a multi-service workflow, or any read-modify-write where the read on one entity gates a write on another, the boundary of "your writes" blurs, and the token stops being a clean handle. This is the argument for skipping session guarantees entirely and making all reads strongly consistent, the way Aurora DSQL does — every read picks a timestamp and any replica blocks until it can answer as of that time. At that point you stop carrying the contract through your code because the database carries it for you. The trade you are weighing is real: per-request complexity in your stack versus paying for a database that makes the problem disappear.

For most services I have built, the honest answer sits in the middle. Sticky-to-primary for the short window right after a write covers the overwhelming majority of "I can't see my own edit" reports with almost no code. The version token is worth threading only on the specific read paths where a stale read is user-visible and the sticky window is too blunt. And reaching for fully strong reads is a call you make when "your writes" has stopped being something a single token can describe.

Takeaways

  • Treat read-your-writes as a property of the session, not a flag on the replica. If you are searching the replica's config for the fix, you are looking in the wrong layer.
  • Reproduce the stale read with a fixed lag before you fix it. A pinned 50 ms turns a heisenbug into a one-line assertion.
  • Pick where the contract lives deliberately: routing (sticky), a carried token (GTID/LSN), or a freshness check (bounded staleness). Each has a different bill.
  • A carried token degrades silently the first time a hop forgets to forward it. If you choose tokens, treat "is the token still attached" as something you test, not assume.
  • Read-your-writes on its own does not buy monotonic reads across replicas. If reads bouncing between replicas can go backwards, you need session affinity or a monotonic high-water mark too.

Reach for sticky-to-primary when the user-visible window is short and you would rather spend a little primary load than thread state through your stack. Reach for version tokens when stale reads are visible on specific paths and you control every hop the token must survive. Reach for fully strong reads when "your writes" has grown past what one token can name — multi-entity workflows, IaC, read-modify-write across services — and you would rather buy the guarantee than maintain it.

Read next

Still here? You might enjoy this.

Nothing close enough — try a different angle?

Was this helpful?

Leave a rating or a quick note — it helps me improve.