Read your own writes, off the primary
PostgreSQL 19’s new WAIT FOR LSN command provides a native, reliable way to route reads to replicas while ensuring specific writes are visible.
Deep dive
- Modern web applications frequently suffer from 'missing' data when reading from replicas immediately after a write.
- Conventional fixes like sleep timeouts or sticky sessions are imprecise and inefficient.
- WAIT FOR LSN instructs a replica to block until it has applied up to a specific WAL position.
- This allows developers to use a 'timeout budget' to decide whether to wait for replica consistency or fall back to the primary.
- The command must be used as a top-level statement and does not function inside transactions above READ COMMITTED.
- It introduces a mechanism to explicitly trade read latency for consistency on a per-request basis.
- This feature is particularly valuable for applications that aggressively offload read traffic to asynchronous standby nodes.
Decoder
- WAL (Write-Ahead Log): A standard method for ensuring data integrity where all changes are recorded sequentially before being applied to the database files.
- LSN (Log Sequence Number): A unique identifier for a position in the WAL stream that tracks the state of database operations.
- Standby/Replica: A secondary database instance that maintains a copy of the primary database's data by continuously replaying WAL records.
Original article
Your API accepts the change. It returns 201 Created or 200 OK, the system has saved the user's change, and the moment the user clicks, the change disappears from the app, only to resurface seconds later. That's if you are lucky. There's no error. The change simply ceased to exist for a while.
In the era of server-side-rendered applications this was a non-issue: either the state was managed as part of a single request, or the user was too slow to outrun the system. The modern application changed that. It fires off the mutation, invalidates the cache, and wakes up the state management, all in parallel and milliseconds after the write. The snappier your frontend feels, the more reliably it outruns your replica.
Throw a collaborative product into the mix and it gets worse, because every change sent over a websocket invalidates state on every teammate's open tab and device, and all of them go back to the same endpoints on the same replicas.
Some of the common workarounds are:
- pin reads to the primary (which you really want to avoid)
- add sleep delays
- set flags in Redis
Your application has to act like a traffic conductor. It guesses the replication lag using timeouts and Redis flags. The replica already knows exactly where it stands; your code just has no way to ask.
PostgreSQL 19 adds a way to ask. On the standby:
WAIT FOR LSN '0/554D1B78';
The standby blocks until it has replayed that position, then returns and lets the next statement run. That is all a reader needs to know to follow the numbers below.
There is a great deal more to it, and my friend Gülçin Yıldırım Jelínek wrote it up last week: why it has to be a top-level command rather than a function, the self-deadlock that rule prevents, and the 2016 proposal it grew out of. Read hers for that; it is the account I kept failing to write, and it saved me a lot of work. Everything below is what happened when I put the statement in front of traffic and measured it.
What makes a replica lag
Replica lag is a combination of factors, and each of them is separately visible. Three of them show up in one query.
SELECT application_name, write_lag, flush_lag, replay_lag,
pg_current_wal_lsn() - replay_lsn AS bytes_behind
FROM pg_stat_replication;
write_lag and flush_lag are the mechanics of the network and the disk on the standby. The WAL segments travelling over the network and hitting storage. In the same availability zone, interactions take just a fraction of a millisecond. However, in cross-region networks, this time continues to increase. This is your floor.
replay_lag is the problem. Shipping WAL is usually simple; applying it is the part that makes your change visible to queries. Recovery is a single startup process replaying records one at a time, and while prefetch can warm the I/O ahead of it, nothing applies two records at once. It competes with everything else the machine is doing, and your change might be queued behind something far more expensive: a bulk import, index maintenance, anything that produces WAL faster than one process can replay it. That is how lag jumps from milliseconds to seconds.
The fourth factor is the shape of the read traffic on the replica, which is ironically the thing you were trying to achieve.
The routing strategies. Measured.
Now back to those common workarounds, measured against WAIT FOR. The workload is straightforward: insert a row on the primary, then read it back like a web request would. Do this 1,000 times with 8 concurrent workers, against a local streaming replica with no added lag.
You shall not synchronously commit
There's one seemingly simple fix that avoids all of this. Make the primary wait for the standby and you never have to look back. It nearly works, which is the problem.
Adding the standby to synchronous_standby_names took staleness from 593 out of 600 reads down to somewhere between 3 and 17, depending on the run. That is a fix that passes every test you'll write and then fails in production, because synchronous_commit = on waits for the standby to flush the WAL, not to replay it, and your read lands between those two instants.
The setting that actually closes the gap is remote_apply, which makes every commit on the primary wait for replay on the standby: the batch job, the migration, and every writer that is never going to read from a replica. WAIT FOR moves that cost to the one reader that needs it, bounded, at the moment it needs the data.
The timeout is a routing budget
WAIT FOR LSN 'lsn'
[ WITH ( option [, ...] ) ]
where option can be:
MODE 'mode'
TIMEOUT 'timeout'
NO_THROW
MODE says what the standby has to have done with the WAL. standby_replay is the default and the only one that helps here: it waits until the record has been replayed and is visible to queries.
TIMEOUT is where a read guarantee stops being a database feature and turns into a system architecture choice.
Implementing it
You may not need any of this. If your reads genuinely tolerate staleness, that is a legitimate architecture and not a gap in it. You will know when you hit the problem.
Every application or framework that can route reads to a replica already ships a sticky-window version of this: pin to the primary for N seconds, sleep, or keep a flag. WAIT FOR replaces the guess with a fact and leaves the rest of the routing alone. Four steps:
- After the commit, on the primary, retrieve the LSN with
SELECT pg_current_wal_flush_lsn(). - Store the LSN wherever the session lives. It's a string like
0/3F8A120identifying a WAL position and it isn't a secret, although anyone who can read it can work out how fast your primary writes. - Before the read, on the replica, run
WAIT FOR LSN '<lsn>' WITH (MODE 'standby_replay', TIMEOUT '…', NO_THROW). Top-level statement, meaning not inside a function or a DO block; an ordinary READ COMMITTED transaction block is fine.NO_THROWturns errors into a status row you can branch on. - Use it.
successmeans you can go ahead with the read on that standby.timeoutornot in recoverymeans read the primary instead, fail the request, or serve the replica's answer with the staleness flagged so a client that applied the write optimistically can keep it.
Which LSN to record
Step 1 said after the commit. The tempting shortcut is to take the LSN while you are already in the transaction and save yourself a round trip.
The token doesn't stop at the session
The LSN is a short string, so it travels: a response header, a field on a job payload, a value you hand to another service. Distributed systems carry this kind of consistency token anyway, and here it happens to be a WAL position.
That has two consequences. If you can receive tokens from more than one source, keep the larger one, because it is the only one that also guarantees the smaller. And don't compare them as strings. Cast to ::pg_lsn and let Postgres do the ordering.
Sample implementation
The whole pattern is about thirty lines: two pools, a validated token, and a branch.
Rails calls it a write
The way through is to go under the statement layer, on the connection the read will use.
Notes regardless of stack
- a transaction block is fine, so wrapped test suites are not a problem.
- your driver will work.
WAIT FORgoes over the extended query protocol every driver speaks. - PgBouncer in transaction mode needs no special handling. The wait goes through as its own implicit transaction, and a read issued afterwards in a separate transaction still sees the write.
Running it yourself
Everything above is in github.com/boringSQL/read-your-writes.
Fresh dev news, AI-digested daily. Never miss a trend at devoured.fyi