Back to blog

The Refresh Bug That Makes Users Think You Deleted Their Work

Sep 15, 2026
Series · Day 9
Solution Architecture in 30 Days
View all lessons →
The Refresh Bug That Makes Users Think You Deleted Their Work

Why this matters

Support ticket lands: user saved a change, hit refresh, and it's gone. Cue panic in the thread — did we lose data? No. The write went through fine on the primary. The refresh just got routed to a replica that hadn't caught up yet. If you've bolted on a read replica to 'scale reads,' this bug is already sitting in your codebase, waiting. It won't show up in load testing. It shows up in the support queue.

Day 9 in one line

Replication buys you availability and read throughput. Fair trade — except it hands you a new variable you now have to manage forever: how fresh is this particular read, right now. Every architecture decision you make after adding a replica has to answer that question, whether you notice it or not.

Anatomy of the bug

  • Client sends a write (save profile, place order, post comment) — it hits the primary and commits.
  • The primary acknowledges the write. Server responds 200 OK, often with a redirect or a follow-up API call to re-fetch the updated resource.
  • That follow-up read gets load-balanced to a replica — because that's the whole point of having replicas.
  • The replica applies changes asynchronously from the primary's write-ahead log. If it hasn't replayed this specific commit yet, it serves the pre-write version of the row.
  • The user watches their own change vanish. To them it looks like data loss. To you it's a race between replication and the read that followed the write.

This is a coin flip, not an edge case

Under normal load, replication lag is small — the replica is usually only a beat behind the primary. Problem is, 'a beat behind' is exactly the timescale of a page refresh or an immediate API re-fetch after a save. Replication catch-up and the user's next read are racing on the same clock. You don't need a slow network or a struggling replica to hit this — you need it to happen on a completely ordinary day. That's why it shows up in production almost immediately, and almost never in a demo, because nobody refreshes fast enough in a demo to notice.

The instinctive wrong fixes

  • Synchronous replication — make the primary wait for the replica to ack before committing. This does fix the race, but now your write latency (and your availability) is only as good as your slowest replica. You've swapped a consistency bug for a latency and availability problem — and you paid that price on every single write, not just the ones that needed it.
  • 'Just add a retry / show a spinner' — papers over the happy path but does nothing for the redirect-after-write case, does nothing for API clients reading their own write, and turns a data problem into a guessing game about how long to spin.
  • Poll until consistent — loop reads against the replica until the value matches what you expect. Fragile (what if it legitimately doesn't match yet, for other reasons?), slow, and you end up hand-rolling an ad hoc consistency protocol one polling loop at a time instead of just deciding one.

The actual fix: read-your-writes routing

You don't need every read in the system to be strongly consistent. You need the writer's own follow-up reads to be. So route on exactly that: after a write, pin that session's (or that request's) reads to the primary for a fixed window, then fall back to replicas once you trust they've caught up. Two ways people actually build this:

  • Sticky session / cookie: on write, set a short-lived marker (cookie, session flag) like 'pin_primary_until = now + 5s'. Any read from that client inside the window goes to primary.
  • Monotonic-read / causal token: on write, capture the primary's commit position (Postgres LSN, MySQL GTID, a logical timestamp). Attach it to the response. On the next read, only route to a replica if that replica's applied position is >= the token; otherwise fall back to primary.
text
function handleWrite(req, session):
    write_to_primary(req)
    session.pin_primary_until = now() + 5_seconds
    # or, if you can read replica lag position:
    session.read_after_lsn = primary.last_commit_lsn()

function routeRead(req, session):
    if session.pin_primary_until and now() < session.pin_primary_until:
        return primary
    if session.read_after_lsn and replica.current_lsn() < session.read_after_lsn:
        return primary
    return replica  # caught up, or no recent write from this session

The token approach is more precise — you're checking an actual position instead of guessing a window — but the sticky-session version is simpler and good enough for most apps. Ship that one first. Upgrade to tokens only if you start seeing false pins under real load.

Where this pattern shows up beyond the demo

This isn't a one-off trick. It's the general shape of 'read-your-writes' consistency, and it recurs everywhere a replica or a cache sits between a write and the reader who cares about it.

  • Managed databases bake this in: DynamoDB has a 'strongly consistent read' flag for exactly this reason, Aurora Global Database's write forwarding exposes a session-level consistency parameter (aurora_replica_read_consistency) for the same problem, and Spanner uses TrueTime to give you external consistency without hand-rolling a token yourself.
  • Causal tokens generalize the LSN trick: 'don't answer this read from anything older than commit X' is the same idea whether X is a database LSN, a Kafka offset, or a cache invalidation timestamp.
  • AI agents hit this harder than humans do. A human refreshes, shrugs, waits a second, refreshes again — intuition quietly papers over the race. An agent that writes a record and immediately reads it back to verify (act, then check, one of the most common agentic patterns there is) has none of that intuition. If that verification read hits a stale replica, the agent concludes its own write failed — and retries it, or 'fixes' something that was never broken, or spirals into a corrective loop that makes the mess worse. If you're building agent tool-use where a write is followed by a self-check read, that self-check needs the same primary-pinning or causal-token discipline as a human-facing UI. Skip it, and you've built a bug factory that argues with itself.

Takeaway for Day 10

A read replica doesn't just add capacity — it changes your consistency model, full stop. The real architectural question was never 'how many replicas do we need.' It's 'who reads from where, and when.' Answer that on purpose, per read path, before you scale out — not after the first support ticket makes you.

Flashcards
Check yourself

Extend your knowledge

  • Read Postgres's docs on streaming replication and pg_stat_replication to see how you'd actually measure replica lag in a running system, instead of just assuming it's negligible.
  • Look at how DynamoDB exposes read-after-write consistency at the API level ('strongly consistent read') and how Aurora Global Database exposes it via its aurora_replica_read_consistency session parameter — both are productized versions of the exact routing decision above.
  • If your ORM or framework already splits primary/replica traffic (Rails' connected_to(role: :writing), Django database routers), check whether it has a sticky-to-primary hook built in before you go build the window logic from scratch.
  • If you're building agent tool-use with a write-then-verify pattern, check whether the verification read goes through the same DB client/session as the write — that's the cheapest way to inherit primary-pinning for free.
Test yourself on this lesson

Discussion

Chat with Chi Cong (AI) about this article. Your conversation is private to you — you can publish a summary for others when you're done.

Ask me anything about “The Refresh Bug That Makes Users Think You Deleted Their Work” — trade-offs, decisions, or the story behind it.