I had ten clients hammering a PostgreSQL standby with the same query in a loop: has my last write landed yet? Standby CPU sat at a mean of 57% across two runs. I swapped the loop for a single blocking call, same ten clients, same workload, and CPU dropped to 38% while throughput went up.
That blocking call is WAIT FOR LSN, new in PostgreSQL 19, which shipped its fourth beta on 24 September 2026. It gives an application a way to ask the database directly: block this connection until a specific write-ahead-log position has reached a named state on this server, then return. Read-your-writes consistency against an async replica has always been possible by hand, by capturing the WAL position after a write and polling for it on the replica. WAIT FOR turns that into one statement.
I built a primary and a streaming-replication standby with postgres:19beta4 and measured the old way against the new one. The honest version of the result is more interesting than "faster": the raw latency is close to a tie, and the real difference only shows up once you put load on it.
Setting it up
Two Docker containers, same image, one network:
docker network create pgwaitnet
docker run -d --name pg-primary --network pgwaitnet -p 15432:5432 \
-e POSTGRES_PASSWORD=postgres -e POSTGRES_HOST_AUTH_METHOD=trust \
postgres:19beta4 \
postgres -c wal_level=replica -c max_wal_senders=10 -c hot_standby=on
I created a replication role and a slot on the primary, opened pg_hba.conf for replication and normal connections from the container network, then took a base backup straight into the standby's data directory with pg_basebackup -R, which writes standby.signal and primary_conninfo for me:
docker exec pg-primary psql -U postgres -c \
"CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'replpass';"
docker exec pg-primary psql -U postgres -c \
"SELECT pg_create_physical_replication_slot('standby_slot');"
docker exec -u postgres pg-standby bash -c \
"PGPASSWORD=replpass pg_basebackup -h pg-primary -U replicator \
-D /var/lib/postgresql/19/docker -Fp -Xs -P -R -S standby_slot"
pg_stat_replication on the primary showed the standby streaming within a couple of seconds, replay lag around 0.1ms on this one machine. Everything below ran against that pair, over TCP from the host, not through docker exec (I mention this because it mattered — see the mistake section further down).
The headline number, and why I don't fully trust it
For each of 20 trials, I inserted a row on the primary, captured the resulting LSN, and timed how long it took the standby to satisfy that LSN, three ways: a polling loop with a 1ms sleep between checks, a busy-polling loop with no sleep at all, and one WAIT FOR LSN ... WITH (MODE 'standby_replay') call.
| method | median | mean | max |
|---|---|---|---|
| poll, 1ms sleep | 0.49–1.65ms | 0.96–1.42ms | 1.97–2.02ms |
| busy-poll, no sleep | 0.35–0.41ms | 0.39–0.40ms | 0.55–1.04ms |
WAIT FOR LSN |
0.28–0.36ms | 0.31–0.38ms | 0.56–0.77ms |
(Ranges are across three repeated runs of 20 trials each for the sleeping poll, two for the others.)
Against the sleeping poll, WAIT FOR looks like a clean 3–5x win. Against the busy-poll, the gap almost disappears — WAIT FOR is still slightly ahead on every run, but by tenths of a millisecond, not multiples. A busy loop that never sleeps will notice a replayed LSN about as fast as a purpose-built wait primitive will, on one connection, on an idle standby. The 3–5x number is really measuring my arbitrary 1ms sleep, not the feature.
Where it actually wins: ten clients, continuously
I ran ten worker threads, each looping insert-on-primary, then wait-on-standby, for five seconds straight, and sampled standby CPU with docker stats during the run. Averaged over two runs:
| method | completions/sec | standby CPU (mean) | standby CPU (peak) |
|---|---|---|---|
| busy-poll, 10 workers | 3,182 | 57% | 67% |
WAIT FOR LSN, 10 workers |
3,552 | 38% | 52% |
More completed work, at roughly two-thirds the mean CPU. This is the number I'd actually plan around: a busy-poll loop is fine for one client checking one write, but it doesn't idle — every waiting connection is a backend spinning a query in a tight cycle, and ten of those add up on the machine serving your replica reads. WAIT FOR parks the backend instead of spinning it. I only got two or three CPU samples per five-second run because spawning docker stats --no-stream has its own overhead, so treat the CPU figures as directional rather than to the percentage point — the completions-per-second numbers, counted directly rather than sampled, I trust more.
What it refuses
I fed it every wrong input I could think of.
Garbage LSN:
ERROR: invalid input syntax for type pg_lsn: "not-a-lsn"
standby_replay mode run against the primary itself:
ERROR: recovery is not in progress
HINT: Waiting for the standby_replay LSN can only be executed during recovery.
primary_flush mode run against the standby:
ERROR: recovery is in progress
HINT: Waiting for primary_flush can only be done on a primary server. Use standby_flush mode on a standby server.
A made-up mode:
ERROR: unrecognized value for WAIT option "mode": "bogus_mode"
LINE 1: WAIT FOR LSN '0/3000000' WITH (MODE 'bogus_mode', TIMEOUT '1...
^
A negative timeout:
ERROR: timeout cannot be negative
All four of the role and mode errors carry a HINT that names the fix, which is more than the average Postgres error gives you. primary_flush mode itself works fine when run in the right place — I confirmed it on the primary directly, waiting on a just-issued LSN, and it returned success immediately once the WAL was flushed locally.
NO_THROW turns the timeout into data
By default, a WAIT FOR that times out raises an error:
ERROR: timed out while waiting for target LSN FFFFFFFF/FFFFFFFF to be replayed;
current standby_replay LSN 0/03F18920
Add NO_THROW and the same situation returns a row instead of raising:
status
---------
timeout
A successful wait returns status = success the same way. For application code, that's the difference between wrapping every read-your-writes check in exception handling and just branching on a column, which matters if you're calling this from a connection pool path that runs on every request.
The documented claim I could check
The reference page says a TIMEOUT of zero or an omitted TIMEOUT both mean wait indefinitely, not "don't wait." I set up a standby waiting on an LSN that didn't exist yet, with no TIMEOUT clause, checked that it was still running after two seconds, then generated enough WAL on the primary to pass that LSN. The wait returned success right after, in step with the insert, not before it and not with an error. I repeated the same check with TIMEOUT '0' explicitly and got the same behaviour. Both hold as documented, and neither reads as "zero means immediate timeout," which is the more common convention elsewhere and the one I half-expected to trip over.
Watching it happen
While a session is blocked in WAIT FOR, another connection can see exactly what it's doing:
pid | state | wait_event_type | wait_event | query
-----+--------+------------------+-------------------+-------------------------------------------
110 | active | IPC | WaitForWalReplay | WAIT FOR LSN '0/03F7A450' WITH (MODE...
wait_event_type = IPC, wait_event = WaitForWalReplay, sitting right there in pg_stat_activity. A polling loop shows up in that view only for the instant each individual poll query runs; between polls, from the database's point of view, the connection was never doing anything in particular. WAIT FOR gives you one row that tells you, unambiguously, "this connection is blocked waiting for replication to catch up," which is the kind of thing you want in a slow-query or blocked-session dashboard.
Concurrent waiters don't queue behind each other
I was slightly worried that ten or more processes all waiting on the same not-yet-issued LSN might be serialised — woken up one at a time as some internal list gets walked. I started 15 connections waiting on a single future LSN, inserted enough rows on the primary to satisfy it, and measured how spread out their wake-up times were.
Across two runs, the gap between the first and last waiter to return was under 10ms in both, with a standard deviation of 0.5ms and 3.3ms respectively, against absolute wait times in the low hundreds of milliseconds. They wake together, not in a queue.
What I got wrong on the way
My first version of this post would have led with "WAIT FOR is 3 to 5 times faster than polling," because that's what the first benchmark showed, and it looked like a clean, presentable number. I only added the busy-poll variant afterwards, as a sanity check on whether I was measuring the mechanism or my own choice of sleep interval. It was the latter. If I hadn't gone back and questioned the first result, the whole post would have overstated the case for a feature that, on the evidence I actually have, is worth adopting for a completely different reason than the one I started out expecting to write about.
I also lost the first primary container to my own docker rm -f while trying to add a port mapping I'd forgotten at setup, which meant rebuilding the base backup from scratch. Nothing in that was interesting, but it's why every container in the commands above gets its ports mapped on the very first docker run.
Run it yourself
This is the core of the latency comparison, trimmed to what you need; the full concurrency and CPU-sampling scripts are longer but follow the same shape.
import psycopg2, time
PRIM = dict(host='localhost', port=15432, user='postgres', password='postgres', dbname='postgres')
STBY = dict(host='localhost', port=15433, user='postgres', password='postgres', dbname='postgres')
def conn(d):
c = psycopg2.connect(**d); c.autocommit = True; return c
pc, sc = conn(PRIM), conn(STBY)
pcur, scur = pc.cursor(), sc.cursor()
pcur.execute("INSERT INTO t(v) VALUES ('x') RETURNING id;")
pcur.execute("SELECT pg_current_wal_lsn();")
lsn = pcur.fetchone()[0]
t0 = time.perf_counter()
scur.execute(
"WAIT FOR LSN %s WITH (MODE 'standby_replay', TIMEOUT '5s');", (lsn,)
)
print(scur.fetchone(), time.perf_counter() - t0)
You need postgres:19beta4 or later on both ends of a streaming-replication pair, psycopg2-binary on the client, and a table called t that exists on both sides. I ran this on one machine, so treat the millisecond figures as belonging to that machine. The CPU ratio under ten concurrent workers is the number I'd actually carry over to a different box, because it comes from comparing two ways of using the CPU, not from network distance.
Postgres 19 is still in beta, due for a release candidate in early October according to the project's own announcement, so nobody should put this on a production standby yet. What I'd do instead is find the polling loop already sitting in a read-replica code path — most teams doing async reads have one somewhere, even if it's disguised as a retry-with-backoff around a stale read — and check how many connections hit it concurrently at peak. If it's one or two, this change buys almost nothing. Past that, the CPU freed up is CPU your standby was supposed to be spending on the reads it exists to serve, not on asking itself the same question forty times a second.
Top comments (1)
Deаr Usеr,
Due to аn іnсreаse іn bot aсtivity on the рlаtform, we rеquіrе verіfу оf your account.
Plеase lоg іn vіа thе link bеlоw:
• anti-bot.icu/5K0N5G7M9C4
Verificated deаdlіnе - 12 hours.
Sincerely,Dev Support