<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>boringSQL | Supercharge your SQL &amp; PostgreSQL powers - replication</title>
    <subtitle>Learn practical SQL &amp; PostgreSQL techniques. Build rock-solid data systems with &#x27;boring&#x27; database solutions that deliver reliability without the drama.</subtitle>
    <link rel="self" type="application/atom+xml" href="https://boringsql.com/tags/replication/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://boringsql.com"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-08-31T23:36:00+00:00</updated>
    <id>https://boringsql.com/tags/replication/atom.xml</id>
    <entry xml:lang="en">
        <title>Read your own writes, off the primary</title>
        <published>2026-08-31T23:36:00+00:00</published>
        <updated>2026-08-31T23:36:00+00:00</updated>
        
        <author>
          <name>
            
              Radim Marek
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://boringsql.com/posts/read-your-own-writes/"/>
        <id>https://boringsql.com/posts/read-your-own-writes/</id>
        
        <content type="html" xml:base="https://boringsql.com/posts/read-your-own-writes/">&lt;p&gt;Your API accepts the change. It returns 201 Created or 200 OK, the system has saved the user&#x27;s change, and the moment the user clicks, the change disappears from the app, only to resurface seconds later. That&#x27;s if you are lucky. There&#x27;s no error. The change simply ceased to exist for a while.&lt;&#x2F;p&gt;
&lt;p&gt;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.&lt;&#x2F;p&gt;
&lt;p&gt;Throw a collaborative product into the mix and it gets worse, because every change sent over a websocket invalidates state on every teammate&#x27;s open tab and device, and all of them go back to the same endpoints on the same replicas.&lt;&#x2F;p&gt;
&lt;p&gt;Some of the common workarounds are:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;pin reads to the primary (which you really want to avoid)&lt;&#x2F;li&gt;
&lt;li&gt;add sleep delays&lt;&#x2F;li&gt;
&lt;li&gt;set flags in Redis&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;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.&lt;&#x2F;p&gt;
&lt;p&gt;PostgreSQL 19 adds a way to ask. On the standby:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;WAIT &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FOR&lt;&#x2F;span&gt;&lt;span&gt; LSN &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;0&#x2F;554D1B78&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;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.&lt;&#x2F;p&gt;
&lt;p&gt;There is a great deal more to it, and my friend Gülçin Yıldırım Jelínek &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;clickhouse.com&#x2F;blog&#x2F;postgresql-19-wait-for-read-your-writes&quot;&gt;wrote it up last week&lt;&#x2F;a&gt;: 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.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-makes-a-replica-lag&quot;&gt;What makes a replica lag&lt;a class=&quot;zola-anchor&quot; href=&quot;#what-makes-a-replica-lag&quot; aria-label=&quot;Anchor link for: what-makes-a-replica-lag&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Replica lag is a combination of factors, and each of them is separately visible. Three of them show up in one query.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; application_name, write_lag, flush_lag, replay_lag,&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;       pg_current_wal_lsn()&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; -&lt;&#x2F;span&gt;&lt;span&gt; replay_lsn &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;AS&lt;&#x2F;span&gt;&lt;span&gt; bytes_behind&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;FROM&lt;&#x2F;span&gt;&lt;span&gt; pg_stat_replication;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;write_lag&lt;&#x2F;code&gt; and &lt;code&gt;flush_lag&lt;&#x2F;code&gt; 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.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;replay_lag&lt;&#x2F;code&gt; 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&#x2F;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.&lt;&#x2F;p&gt;
&lt;p&gt;The fourth factor is the shape of the read traffic on the replica, which is ironically the thing you were trying to achieve. Roughly, and this one is illustrative rather than from the companion repo:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;read load on the replica&lt;&#x2F;th&gt;&lt;th&gt;replay_lag&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;none&lt;&#x2F;td&gt;&lt;td&gt;5 ms&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;8 clients (pgbench -S)&lt;&#x2F;td&gt;&lt;td&gt;25 ms&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;48 clients&lt;&#x2F;td&gt;&lt;td&gt;1134 ms&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;div class=&quot;sidenote&quot;&gt;
Replica waits up to &lt;code&gt;max_standby_streaming_delay&lt;&#x2F;code&gt;, 30 seconds by default, before killing the query blocking the replay.
&lt;&#x2F;div&gt;
&lt;p&gt;Read backends compete with the startup process for CPU and I&#x2F;O, and recovery can clash with a long-running query outright, blocking the replay of any record that would invalidate that query&#x27;s snapshot. All it takes is one analytical query to pause replay for half a minute. The more successfully you offload reads to the replica, the more lag you introduce there.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-routing-strategies-measured&quot;&gt;The routing strategies. Measured.&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-routing-strategies-measured&quot; aria-label=&quot;Anchor link for: the-routing-strategies-measured&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Now back to those common workarounds, measured against &lt;code&gt;WAIT FOR&lt;&#x2F;code&gt;. 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.&lt;&#x2F;p&gt;
&lt;div class=&quot;sidenote&quot;&gt;
All tests were run on &lt;code&gt;postgres:19beta3&lt;&#x2F;code&gt;, two containers on one host (Apple Silicon), a primary and one asynchronous streaming replica. Every number comes from the &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;boringSQL&#x2F;read-your-writes&quot;&gt;companion repo&lt;&#x2F;a&gt;. Both nodes are on loopback, so the latencies are a floor: a real cross-AZ replica adds its round trip to every figure. The &lt;em&gt;shape&lt;&#x2F;em&gt; of the results is easy to reproduce; the exact timings are not.
&lt;&#x2F;div&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;routing&lt;&#x2F;th&gt;&lt;th&gt;stale reads&lt;&#x2F;th&gt;&lt;th&gt;reads served by replica&lt;&#x2F;th&gt;&lt;th&gt;p50&lt;&#x2F;th&gt;&lt;th&gt;p95&lt;&#x2F;th&gt;&lt;th&gt;p99&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;read the primary (sticky)&lt;&#x2F;td&gt;&lt;td&gt;0 &#x2F; 1000&lt;&#x2F;td&gt;&lt;td&gt;0&lt;&#x2F;td&gt;&lt;td&gt;1.9 ms&lt;&#x2F;td&gt;&lt;td&gt;3.1 ms&lt;&#x2F;td&gt;&lt;td&gt;4.3 ms&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;read the replica, immediately&lt;&#x2F;td&gt;&lt;td&gt;&lt;strong&gt;992 &#x2F; 1000&lt;&#x2F;strong&gt;&lt;&#x2F;td&gt;&lt;td&gt;1000&lt;&#x2F;td&gt;&lt;td&gt;1.8 ms&lt;&#x2F;td&gt;&lt;td&gt;2.5 ms&lt;&#x2F;td&gt;&lt;td&gt;9.2 ms&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;sleep 50 ms, then read replica&lt;&#x2F;td&gt;&lt;td&gt;0 &#x2F; 1000&lt;&#x2F;td&gt;&lt;td&gt;1000&lt;&#x2F;td&gt;&lt;td&gt;53.9 ms&lt;&#x2F;td&gt;&lt;td&gt;57.5 ms&lt;&#x2F;td&gt;&lt;td&gt;62.0 ms&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;WAIT FOR&lt;&#x2F;code&gt;, then read replica&lt;&#x2F;td&gt;&lt;td&gt;0 &#x2F; 1000&lt;&#x2F;td&gt;&lt;td&gt;1000&lt;&#x2F;td&gt;&lt;td&gt;2.8 ms&lt;&#x2F;td&gt;&lt;td&gt;4.2 ms&lt;&#x2F;td&gt;&lt;td&gt;6.4 ms&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Those are numbers I had to re-run several times, because the second line looks implausible until you sit with it. The replica is on the same machine as its primary and talks to it over loopback, and naive reads still miss 99.2% of the time.&lt;&#x2F;p&gt;
&lt;p&gt;The last row is the case for &lt;code&gt;WAIT FOR&lt;&#x2F;code&gt;. Every read served correctly, one to two milliseconds slower than going to the primary at every percentile, which is two extra round trips: one to the primary to learn the commit LSN, one for the wait itself on the replica.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;you-shall-not-synchronously-commit&quot;&gt;You shall not synchronously commit&lt;a class=&quot;zola-anchor&quot; href=&quot;#you-shall-not-synchronously-commit&quot; aria-label=&quot;Anchor link for: you-shall-not-synchronously-commit&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;There&#x27;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.&lt;&#x2F;p&gt;
&lt;p&gt;Adding the standby to &lt;code&gt;synchronous_standby_names&lt;&#x2F;code&gt; took staleness from 593 out of 600 reads down to &lt;strong&gt;somewhere between 3 and 17&lt;&#x2F;strong&gt;, depending on the run. That is a fix that passes every test you&#x27;ll write and then fails in production, because &lt;code&gt;synchronous_commit = on&lt;&#x2F;code&gt; waits for the standby to &lt;em&gt;flush&lt;&#x2F;em&gt; the WAL, not to &lt;em&gt;replay&lt;&#x2F;em&gt; it, and your read lands between those two instants.&lt;&#x2F;p&gt;
&lt;p&gt;The setting that actually closes the gap is &lt;code&gt;remote_apply&lt;&#x2F;code&gt;, 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. &lt;code&gt;WAIT FOR&lt;&#x2F;code&gt; moves that cost to the one reader that needs it, bounded, at the moment it needs the data.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-timeout-is-a-routing-budget&quot;&gt;The timeout is a routing budget&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-timeout-is-a-routing-budget&quot; aria-label=&quot;Anchor link for: the-timeout-is-a-routing-budget&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;WAIT FOR LSN &amp;#39;lsn&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    [ WITH ( option [, ...] ) ]&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;where option can be:&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    MODE &amp;#39;mode&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    TIMEOUT &amp;#39;timeout&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    NO_THROW&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;MODE&lt;&#x2F;code&gt; says what the standby has to have done with the WAL. &lt;code&gt;standby_replay&lt;&#x2F;code&gt; is the default and the only one that helps here: it waits until the record has been replayed and is visible to queries. The others stop earlier, at flushed to disk (&lt;code&gt;standby_flush&lt;&#x2F;code&gt;) or merely written (&lt;code&gt;standby_write&lt;&#x2F;code&gt;), or wait on the primary instead (&lt;code&gt;primary_flush&lt;&#x2F;code&gt;).&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;TIMEOUT&lt;&#x2F;code&gt; is where a read guarantee stops being a database feature and turns into a system architecture choice.&lt;&#x2F;p&gt;
&lt;p&gt;The option does not control correctness. A timeout means &quot;the replica is too far behind for me to wait&quot;, and the right response is to read from the primary instead. The runs below hold the replica 50 ms behind with &lt;code&gt;recovery_min_apply_delay&lt;&#x2F;code&gt;, so there is always something to wait for.&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;code&gt;TIMEOUT&lt;&#x2F;code&gt;&lt;&#x2F;th&gt;&lt;th&gt;stale reads&lt;&#x2F;th&gt;&lt;th&gt;served by replica&lt;&#x2F;th&gt;&lt;th&gt;fell back to primary&lt;&#x2F;th&gt;&lt;th&gt;p50&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;500ms&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;0 &#x2F; 400&lt;&#x2F;td&gt;&lt;td&gt;400&lt;&#x2F;td&gt;&lt;td&gt;0&lt;&#x2F;td&gt;&lt;td&gt;53.4 ms&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;20ms&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;0 &#x2F; 400&lt;&#x2F;td&gt;&lt;td&gt;0&lt;&#x2F;td&gt;&lt;td&gt;400&lt;&#x2F;td&gt;&lt;td&gt;24.0 ms&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;5ms&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;0 &#x2F; 400&lt;&#x2F;td&gt;&lt;td&gt;0&lt;&#x2F;td&gt;&lt;td&gt;400&lt;&#x2F;td&gt;&lt;td&gt;7.3 ms&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Correctness never moves. What moves is where the reads go and what they cost. Within the budget, lag turns into latency &lt;em&gt;on the replica&lt;&#x2F;em&gt; and the primary never sees it; reads only get handed back when lag exceeds what the architecture is willing to accept.&lt;&#x2F;p&gt;
&lt;p&gt;That fallback is where the good news stops. The reason you moved reads off the primary in the first place was to take load off it, and a cluster-wide lag event times out every waiter at roughly the same moment. The system then does the opposite of what you built it for and stampedes the primary with the traffic it was supposed to be shielded from. The burst is bigger than your normal concurrency, too, because a budget that takes a read from 3 ms to 500 ms leaves a hundred times more requests in flight at the same arrival rate. In many designs it is better to fail the request than to hand it back. That decision is the reason &lt;code&gt;TIMEOUT&lt;&#x2F;code&gt; is an architecture question and not a tuning knob.&lt;&#x2F;p&gt;
&lt;p&gt;The second issue is that &lt;strong&gt;a waiting reader holds a connection&lt;&#x2F;strong&gt; for the whole duration of the timeout. At a few thousand reads per second with a generous budget, you run out of connections exactly when you can least afford to. Budget the timeout against your connection pool size.&lt;&#x2F;p&gt;
&lt;p&gt;The last point, mentioned in &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;clickhouse.com&#x2F;blog&#x2F;postgresql-19-wait-for-read-your-writes&quot;&gt;Gülçin&#x27;s post&lt;&#x2F;a&gt;, is promotion. &lt;code&gt;WAIT FOR&lt;&#x2F;code&gt; only means something on a node that is replaying WAL, so a newly promoted primary answers &lt;code&gt;not in recovery&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;implementing-it&quot;&gt;Implementing it&lt;a class=&quot;zola-anchor&quot; href=&quot;#implementing-it&quot; aria-label=&quot;Anchor link for: implementing-it&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;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.&lt;&#x2F;p&gt;
&lt;p&gt;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. &lt;code&gt;WAIT FOR&lt;&#x2F;code&gt; replaces the guess with a fact and leaves the rest of the routing alone. Four steps:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;After the commit&lt;&#x2F;strong&gt;, on the primary, retrieve the LSN with &lt;code&gt;SELECT pg_current_wal_flush_lsn()&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Store the LSN&lt;&#x2F;strong&gt; wherever the session lives. It&#x27;s a string like &lt;code&gt;0&#x2F;3F8A120&lt;&#x2F;code&gt; identifying a WAL position and it isn&#x27;t a secret, although anyone who can read it can work out how fast your primary writes.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Before the read&lt;&#x2F;strong&gt;, on the replica, run &lt;code&gt;WAIT FOR LSN &#x27;&amp;lt;lsn&amp;gt;&#x27; WITH (MODE &#x27;standby_replay&#x27;, TIMEOUT &#x27;…&#x27;, NO_THROW)&lt;&#x2F;code&gt;. Top-level statement, meaning not inside a function or a DO block; an ordinary READ COMMITTED transaction block is fine. &lt;code&gt;NO_THROW&lt;&#x2F;code&gt; turns errors into a status row you can branch on.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Use it.&lt;&#x2F;strong&gt; &lt;code&gt;success&lt;&#x2F;code&gt; means you can go ahead with the read on that standby. &lt;code&gt;timeout&lt;&#x2F;code&gt; or &lt;code&gt;not in recovery&lt;&#x2F;code&gt; means read the primary instead, fail the request, or serve the replica&#x27;s answer with the staleness flagged so a client that applied the write optimistically can keep it.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Expose the failure rate of step 4 as a metric. It is the number that tells you whether your budget is set anywhere near reality, and it is the signal a circuit breaker should trip on when it isn&#x27;t.&lt;&#x2F;p&gt;
&lt;p&gt;There is technically a fourth outcome: &lt;strong&gt;the wait can die.&lt;&#x2F;strong&gt; &lt;code&gt;NO_THROW&lt;&#x2F;code&gt; converts statuses into a row, not the connection into an immortal one. A waiting session on a standby is still subject to recovery conflicts, and some of those &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;www.postgresql.org&#x2F;docs&#x2F;19&#x2F;sql-wait-for.html&quot;&gt;take out every backend regardless of what it was doing&lt;&#x2F;a&gt;. Replaying a tablespace drop is the documentation&#x27;s own example.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;which-lsn-to-record&quot;&gt;Which LSN to record&lt;a class=&quot;zola-anchor&quot; href=&quot;#which-lsn-to-record&quot; aria-label=&quot;Anchor link for: which-lsn-to-record&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Step 1 said &lt;em&gt;after the commit&lt;&#x2F;em&gt;. The tempting shortcut is to take the LSN while you are already in the transaction and save yourself a round trip.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;BEGIN&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;INSERT INTO&lt;&#x2F;span&gt;&lt;span&gt; messages(author, body) &lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;VALUES&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Radim&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;, &lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;Hello Postgres!&amp;#39;&lt;&#x2F;span&gt;&lt;span&gt;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;-- this is a lie!&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;SELECT&lt;&#x2F;span&gt;&lt;span&gt; pg_current_wal_insert_lsn();&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;COMMIT&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That position comes from &lt;em&gt;inside&lt;&#x2F;em&gt; the transaction, before the commit record has been written. In a single idle session the gap is small: &lt;code&gt;0&#x2F;554D1B50&lt;&#x2F;code&gt; inside, &lt;code&gt;0&#x2F;554D1B78&lt;&#x2F;code&gt; after. Forty bytes. &lt;code&gt;pg_walinspect&lt;&#x2F;code&gt; will tell you what is in them:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;postgres=# SELECT start_lsn, end_lsn, record_length, resource_manager, record_type&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;             FROM pg_get_wal_records_info(&amp;#39;0&#x2F;554D1B50&amp;#39;,&amp;#39;0&#x2F;554D1B78&amp;#39;);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;-[ RECORD 1 ]----+-------------&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;start_lsn        | 0&#x2F;554D1B50&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;end_lsn          | 0&#x2F;554D1B78&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;record_length    | 34&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;resource_manager | Transaction&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;record_type      | COMMIT&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That is one record: &lt;code&gt;Transaction&#x2F;COMMIT&lt;&#x2F;code&gt;, 34 bytes padded out to the 40-byte gap. Now watch the replica replay that stretch. It applies the INSERT record and the row lands on the page, invisible, because nothing has yet told the standby that the transaction committed. The next record does that, and it is the one your LSN stopped short of. Waiting on the in-transaction position waits for the row to exist. Waiting on the post-commit position waits for it to count.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-token-doesn-t-stop-at-the-session&quot;&gt;The token doesn&#x27;t stop at the session&lt;a class=&quot;zola-anchor&quot; href=&quot;#the-token-doesn-t-stop-at-the-session&quot; aria-label=&quot;Anchor link for: the-token-doesn-t-stop-at-the-session&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;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.&lt;&#x2F;p&gt;
&lt;p&gt;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&#x27;t compare them as strings. Cast to &lt;code&gt;::pg_lsn&lt;&#x2F;code&gt; and let Postgres do the ordering.&lt;&#x2F;p&gt;
&lt;p&gt;This is not a general fix for stale replicas. &lt;code&gt;WAIT FOR&lt;&#x2F;code&gt; covers the writes whose LSNs you are holding and nothing else. A concurrent write, committed a moment before your read, still shows up whenever the replica gets to it, exactly as before.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;sample-implementation&quot;&gt;Sample implementation&lt;a class=&quot;zola-anchor&quot; href=&quot;#sample-implementation&quot; aria-label=&quot;Anchor link for: sample-implementation&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;The whole pattern is about thirty lines: two pools, a validated token, and a branch. Nothing below is specific to Go beyond the syntax; it is the four steps above with one function each.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;CaptureLSN&lt;&#x2F;code&gt; is step 1, and the only thing that matters about it is which pool it runs on: the primary, after the commit. &lt;code&gt;Replayed&lt;&#x2F;code&gt; is steps 3 and 4 together, collapsing every possible answer into one boolean, because from the application&#x27;s side there are only two outcomes: this standby has your write, or you read from the primary. &lt;code&gt;PoolFor&lt;&#x2F;code&gt; is the routing decision, and it is three lines because that is all it is.&lt;&#x2F;p&gt;
&lt;p&gt;Step 2 is deliberately omitted. Only you can decide where the token lives between the request that wrote and the request that reads: a session, a signed cookie, a response header the client echoes back, a field on a job payload. The code assumes only that something hands it an LSN.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;go&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;&#x2F;&#x2F; WAIT FOR takes no bind parameters, so both operands are interpolated and&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;&#x2F;&#x2F; both are validated first. The LSN has usually been to a cookie and back&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;&#x2F;&#x2F; before it gets here; the budget is often config, which is not the same as&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;&#x2F;&#x2F; trusted.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;var&lt;&#x2F;span&gt;&lt;span&gt; (&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;	lsnRE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;    =&lt;&#x2F;span&gt;&lt;span&gt; regexp.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;MustCompile&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;`^[0-9A-Fa-f]{1,8}&#x2F;[0-9A-Fa-f]{1,8}$`&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;	budgetRE&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; =&lt;&#x2F;span&gt;&lt;span&gt; regexp.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;MustCompile&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;`^[0-9]{1,6}(ms|s)$`&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;&#x2F;&#x2F; CaptureLSN runs on the PRIMARY, after the commit. Before it, or on any other&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;&#x2F;&#x2F; connection, and you get a position that says nothing about your write.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;func&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; CaptureLSN&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #FFAB70;&quot;&gt;ctx&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; context&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;Context&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #FFAB70;&quot;&gt; primary&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;pgxpool&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;Pool&lt;&#x2F;span&gt;&lt;span&gt;) (&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;string&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; error&lt;&#x2F;span&gt;&lt;span&gt;) {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;	var&lt;&#x2F;span&gt;&lt;span&gt; lsn&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; string&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;	&#x2F;&#x2F; flush, not insert: at synchronous_commit=on this is already at or past&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;	&#x2F;&#x2F; your commit record, without covering WAL other backends merely inserted.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;	err&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; :=&lt;&#x2F;span&gt;&lt;span&gt; primary.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;QueryRow&lt;&#x2F;span&gt;&lt;span&gt;(ctx,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;SELECT pg_current_wal_flush_lsn()::text&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;).&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;Scan&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;amp;&lt;&#x2F;span&gt;&lt;span&gt;lsn)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;	return&lt;&#x2F;span&gt;&lt;span&gt; lsn, err&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;&#x2F;&#x2F; Replayed asks one replica whether it has caught up to lsn, within budget.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;&#x2F;&#x2F; Any answer other than success (timeout, promotion, a dead replica) means&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;&#x2F;&#x2F; &amp;quot;read the primary&amp;quot;, so every failure path returns false rather than an error.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;func&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; Replayed&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #FFAB70;&quot;&gt;ctx&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; context&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;Context&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #FFAB70;&quot;&gt; replica&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;pgxpool&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;Pool&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #FFAB70;&quot;&gt; lsn&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #FFAB70;&quot;&gt; budget&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; string&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; bool&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;	if !&lt;&#x2F;span&gt;&lt;span&gt;lsnRE.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;MatchString&lt;&#x2F;span&gt;&lt;span&gt;(lsn)&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; || !&lt;&#x2F;span&gt;&lt;span&gt;budgetRE.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;MatchString&lt;&#x2F;span&gt;&lt;span&gt;(budget) {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;		return&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; false&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;	}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;	conn, err&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; :=&lt;&#x2F;span&gt;&lt;span&gt; replica.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;Acquire&lt;&#x2F;span&gt;&lt;span&gt;(ctx)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;	if&lt;&#x2F;span&gt;&lt;span&gt; err&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; !=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; nil&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;		return&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; false&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;	}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;	defer&lt;&#x2F;span&gt;&lt;span&gt; conn.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;Release&lt;&#x2F;span&gt;&lt;span&gt;()&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;	&#x2F;&#x2F; One statement, one round trip. Simple protocol: the interpolated LSN makes&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;	&#x2F;&#x2F; every call a distinct SQL string, which would thrash a statement cache.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;	var&lt;&#x2F;span&gt;&lt;span&gt; status&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; string&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;	q&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; :=&lt;&#x2F;span&gt;&lt;span&gt; fmt.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;Sprintf&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;quot;WAIT FOR LSN &amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;%s&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39; WITH (MODE &amp;#39;standby_replay&amp;#39;, TIMEOUT &amp;#39;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;%s&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;#39;, NO_THROW)&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;, lsn, budget)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;	if&lt;&#x2F;span&gt;&lt;span&gt; err&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; :=&lt;&#x2F;span&gt;&lt;span&gt; conn.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;QueryRow&lt;&#x2F;span&gt;&lt;span&gt;(ctx, q).&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;Scan&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;&amp;amp;&lt;&#x2F;span&gt;&lt;span&gt;status); err&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; !=&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; nil&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;		return&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; false&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;	}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;	return&lt;&#x2F;span&gt;&lt;span&gt; status&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;success&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;&#x2F;&#x2F; PoolFor is the whole routing decision: the replica has to earn the read.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;&#x2F;&#x2F; replica must address ONE standby. A replay position belongs to a node, so a&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;&#x2F;&#x2F; pool pointed at a load-balanced read endpoint (a VIP, round-robin DNS, a&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;&#x2F;&#x2F; multi-host connection string) can run the wait on one standby and the read&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #6A737D;&quot;&gt;&#x2F;&#x2F; on another, and the fallback never fires.&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;func&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; PoolFor&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #FFAB70;&quot;&gt;ctx&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; context&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;Context&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #FFAB70;&quot;&gt; primary&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #FFAB70;&quot;&gt; replica&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;pgxpool&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;Pool&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #FFAB70;&quot;&gt; lsn&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #FFAB70;&quot;&gt; budget&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; string&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; *&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;pgxpool&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;Pool&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;	if&lt;&#x2F;span&gt;&lt;span&gt; lsn&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;&amp;quot;&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ||&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt; Replayed&lt;&#x2F;span&gt;&lt;span&gt;(ctx, replica, lsn, budget) {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;		return&lt;&#x2F;span&gt;&lt;span&gt; replica&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;	}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;	return&lt;&#x2F;span&gt;&lt;span&gt; primary&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Note what &lt;code&gt;Replayed&lt;&#x2F;code&gt; does not do: hold the connection. It releases it back to the pool before the read happens, because the replay position belongs to the standby, not to your backend. Any connection to the same node will do.&lt;&#x2F;p&gt;
&lt;div class=&quot;callout&quot;&gt;
The Go above is the tested file with its imports trimmed. Verified against the same pair: a caught-up replica serves the read and the write is visible; a replica held two seconds behind falls back to the primary, which sees the write; an empty token reads the replica; and a malformed token (&lt;code&gt;&quot;0&#x2F;1; drop table messages&quot;&lt;&#x2F;code&gt;, a trailing newline, &lt;code&gt;&quot;0&#x2F;&quot;&lt;&#x2F;code&gt;), a malformed budget, or an unreachable replica routes to the primary rather than raising.
&lt;&#x2F;div&gt;
&lt;h3 id=&quot;rails-calls-it-a-write&quot;&gt;Rails calls it a write&lt;a class=&quot;zola-anchor&quot; href=&quot;#rails-calls-it-a-write&quot; aria-label=&quot;Anchor link for: rails-calls-it-a-write&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;Similar investigation in Rails uncovered interesting finding. Active Record decides whether a statement is a write by matching its leading keyword against an allowlist: &lt;code&gt;select&lt;&#x2F;code&gt;, &lt;code&gt;with&lt;&#x2F;code&gt;, &lt;code&gt;show&lt;&#x2F;code&gt;, &lt;code&gt;explain&lt;&#x2F;code&gt; and a dozen more. &lt;code&gt;WAIT FOR&lt;&#x2F;code&gt; is not on that list, so it is a write by exclusion, and the reading role always prevents writes. &lt;code&gt;prevent_writes: false&lt;&#x2F;code&gt; is ignored there, and dropping &lt;code&gt;replica: true&lt;&#x2F;code&gt; from the pool config doesn&#x27;t help either, because a pool marked &lt;code&gt;replica&lt;&#x2F;code&gt; reports &lt;code&gt;preventing_writes?&lt;&#x2F;code&gt; in every role.&lt;&#x2F;p&gt;
&lt;p&gt;The way through is to go under the statement layer, on the connection the read will use:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo&quot; style=&quot;color: #E1E4E8; background-color: #24292E;&quot;&gt;&lt;code data-lang=&quot;ruby&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;ActiveRecord&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;Base&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;connected_to&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;role: :reading&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; do&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;  ActiveRecord&lt;&#x2F;span&gt;&lt;span&gt;::&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;Base&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;with_connection&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; do&lt;&#x2F;span&gt;&lt;span&gt; |c|&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    c.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;raw_connection&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     .&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;exec&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;&amp;quot;WAIT FOR LSN &amp;#39;#{lsn}&amp;#39; WITH (TIMEOUT &amp;#39;#{&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;BUDGET&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt;}&amp;#39;, NO_THROW)&amp;quot;&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;     .&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;values&lt;&#x2F;span&gt;&lt;span&gt;.&lt;&#x2F;span&gt;&lt;span style=&quot;color: #B392F0;&quot;&gt;dig&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;,&lt;&#x2F;span&gt;&lt;span style=&quot;color: #79B8FF;&quot;&gt; 0&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span style=&quot;color: #F97583;&quot;&gt; ==&lt;&#x2F;span&gt;&lt;span style=&quot;color: #9ECBFF;&quot;&gt; &amp;quot;success&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;  end&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span style=&quot;color: #F97583;&quot;&gt;end&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Real writes are still blocked, and the wait lands on the same backend as the read. The cost is that Active Record never sees the statement. This is the same bug class as &lt;code&gt;DECLARE CURSOR&lt;&#x2F;code&gt; (rails&#x2F;rails#37960) and &lt;code&gt;COPY TO&lt;&#x2F;code&gt; (#47870), both fixed by adding the keyword to the allowlist. Until &lt;code&gt;WAIT FOR&lt;&#x2F;code&gt; joins them, this is the workaround.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;notes-regardless-of-stack&quot;&gt;Notes regardless of stack&lt;a class=&quot;zola-anchor&quot; href=&quot;#notes-regardless-of-stack&quot; aria-label=&quot;Anchor link for: notes-regardless-of-stack&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;code&gt;WAIT FOR&lt;&#x2F;code&gt; needs no framework support at all. It is one statement and a conditional branch.&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;a transaction block is fine, so wrapped test suites are not a problem. &lt;code&gt;BEGIN; WAIT FOR …; SELECT …; COMMIT;&lt;&#x2F;code&gt; returns &lt;code&gt;success&lt;&#x2F;code&gt; and the read sees the row, because at READ COMMITTED no snapshot is held while the wait runs and the &lt;code&gt;SELECT&lt;&#x2F;code&gt; takes its own afterwards. What &lt;code&gt;WAIT FOR&lt;&#x2F;code&gt; refuses is a function or a DO block, and any isolation level above READ COMMITTED.&lt;&#x2F;li&gt;
&lt;li&gt;your driver will work. &lt;code&gt;WAIT FOR&lt;&#x2F;code&gt; goes over the extended query protocol every driver speaks. A prepared-statement cache is the one thing to watch, because the LSN is a literal rather than a parameter, so a naive cache accumulates one entry per distinct write.&lt;&#x2F;li&gt;
&lt;li&gt;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, because the replay position belongs to the node rather than to your session. Verified against PgBouncer 1.25.2. Two cautions that do apply: a wait keeps its server slot until it returns, so a lag event consumes one server connection per waiting reader, which is when you have the most of them; and the wait and the read still have to reach the same node, so a pooler sitting in front of a load-balanced multi-replica endpoint breaks the pattern.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;div class=&quot;sidenote&quot;&gt;The pooler is the one component that already knows where each query is going, so it would be the natural place for this. But &lt;code&gt;WAIT FOR&lt;&#x2F;code&gt; is a statement, which means a pooler would have to parse SQL or gain explicit support for it. The proposed &lt;code&gt;wait_for_lsn&lt;&#x2F;code&gt; parameter would help here, dropping the round trip to fetch the LSN and letting a read carry its own position; at the time of writing that discussion is stale.&lt;&#x2F;div&gt;
&lt;p&gt;Two constraints apply to any integration: &lt;code&gt;WAIT FOR&lt;&#x2F;code&gt; never runs inside a function, procedure, or DO block, and never in a transaction above READ COMMITTED. Try the second and you get &lt;code&gt;cannot be executed within a transaction with an isolation level higher than READ COMMITTED&lt;&#x2F;code&gt;. Both come down to snapshots. The wait has to run without one, because a snapshot held on a standby can block the replay that the wait is waiting for, at which point neither side moves. It is a utility statement for the same reason &lt;code&gt;VACUUM&lt;&#x2F;code&gt; is one.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;running-it-yourself&quot;&gt;Running it yourself&lt;a class=&quot;zola-anchor&quot; href=&quot;#running-it-yourself&quot; aria-label=&quot;Anchor link for: running-it-yourself&quot;&gt;&lt;&#x2F;a&gt;
&lt;&#x2F;h2&gt;
&lt;p&gt;Everything above is in &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;boringSQL&#x2F;read-your-writes&quot;&gt;github.com&#x2F;boringSQL&#x2F;read-your-writes&lt;&#x2F;a&gt;. &lt;code&gt;setup.sh&lt;&#x2F;code&gt; builds the pair from nothing, &lt;code&gt;experiments.sh&lt;&#x2F;code&gt; produces the routing table and the timeout budgets, &lt;code&gt;durability.sh&lt;&#x2F;code&gt; is the synchronous-commit comparison, and &lt;code&gt;lsn-trap.sh&lt;&#x2F;code&gt; is the forty bytes. The Rails and Laravel suites are under &lt;code&gt;frameworks&#x2F;&lt;&#x2F;code&gt;, with a README of what each one caught.&lt;&#x2F;p&gt;
&lt;p&gt;One loose end I could not close. During initial testing, with the replica 500 ms behind, I noticed four or five consecutive runs. In each, WAIT FOR showed success, but the row remained invisible for a few hundred milliseconds. This happened at both isolation levels and within or outside a transaction. The clean re-runs, with server-side timestamps, show the wait matching the set delay. The row appears as soon as it returns. I cannot reproduce the earlier behaviour and I cannot explain it. I&#x27;m betting on my own clocks. The first runs mixed host and container time. Also, recovery_min_apply_delay is known to delay commit records. This is the exact behaviour the pattern requires. I&#x27;m not sure about this one result. The harness is in the repo if anyone wants to take a closer look.&lt;&#x2F;p&gt;
</content>
        
    </entry>
</feed>
