Synchronous Replication and the Durability Dial
Replication is asynchronous unless you say otherwise. The primary flushes its own WAL, returns from COMMIT, and gets on with the next transaction; whether any replica has that record yet is a separate question with its own answer arriving milliseconds later. That is the right default for most systems, and it has a price that is only ever paid once, all at once.
If pg-primary's hardware fails at 11:04:07.140 and pg-replica-b's replay lag at that instant was 380 milliseconds, the orders committed inside that window exist nowhere but on a machine that is not coming back. Synchronous replication removes that loss by making the commit wait for a standby to confirm. It costs a network round trip on every commit that opts in, and configured without care it means one rebooting replica stops the site from accepting writes at all.
What Asynchronous Really Costs
The recovery point objective of an unplanned failover is exactly the replica's lag at the moment the primary died. That is the whole formula, and it makes the cost measurable rather than theoretical. Cartwheel peaks at 3,000 orders a minute on Saturday morning, which is 50 a second: at 380 milliseconds of lag a failover loses about 19 orders, and at the 41 minutes pg-replica-a reached in the previous topic it would lose roughly 123,000 of them. The number that matters is the tail of the lag distribution during the busiest hour, not the average on a quiet Tuesday, because the busiest hour is also the one the hardware is most likely to fail in.
Then price it against what the rows are. Nineteen delivery_events rows are a gap in a courier's breadcrumb trail that nobody will ever notice. Nineteen orders whose payment was captured by a third-party processor that has no idea Postgres lost them is a reconciliation problem with nineteen customers attached, and it arrives by email over the following week. Both descriptions are of the same 380 milliseconds on the same replica.
The Levels, Applied Remotely
synchronous_commit defaults to on, and Chapter 12 established what that means locally: the commit waits for the WAL flush to durable storage on this machine. Name a synchronous standby and three of the values grow a second half. remote_write waits until the standby has received the commit record and written it to its file system, which survives a Postgres crash on the standby but not an operating-system crash there. on waits until the standby has flushed it to durable storage. remote_apply waits until the standby has applied it, so it is visible to queries running there and written durably as well. local waits for the local flush and ignores replication entirely, and off waits for nothing. With synchronous_standby_names empty, all of that collapses: remote_apply, remote_write and local behave identically to on, and only on and off mean anything at all.
remote_applyonremote_writelocaloff# postgresql.conf on pg-primary synchronous_standby_names = 'ANY 1 (replica_a, replica_b)' synchronous_commit = local # nothing waits for the network by default -- the payment path opts up, one transaction at a time BEGIN; SET LOCAL synchronous_commit = remote_apply; UPDATE orders SET status = 'paid' WHERE id = 1352914702; INSERT INTO delivery_events (order_id, event_type, occurred_at, payload) VALUES (1352914702, 'payment_captured', now(), '{"processor":"stripe"}'); COMMIT; -- returns once a standby has applied it
Naming standbys and setting the global level to local looks contradictory and is the sane starting shape: the machinery is armed, and each write path decides for itself whether to use it. Every step up the remote ladder adds a round trip, and remote_apply adds something else that is easy to miss. Its latency includes the standby's replay, which means it is coupled to whatever that standby's single replay process is already working through: a conflict, a checkpoint's worth of full-page writes, a long report holding a lock. Choosing remote_apply against a standby that also serves analytics ties commit latency on the primary to query behaviour on another machine, and pg-replica-b exists partly so that pairing never has to be made.
Quorum Syntax and the Single-Standby Trap
The name in synchronous_standby_names is the standby's application_name, which is set in its primary_conninfo and defaults to cluster_name if that is set, otherwise to the literal walreceiver. Names are compared case-insensitively, uniqueness is not enforced, and * matches anything. Three syntaxes are accepted: a bare list, which behaves as priority-based; FIRST n (…), explicitly priority-based, where the top n live standbys in list order are the synchronous ones and a failure is replaced immediately by the next candidate; and ANY n (…), quorum-based, where a commit proceeds once any n of the listed standbys have replied.
Here is the line that decides whether this configuration is survivable. Writing synchronous_standby_names = 'replica_b' names exactly one standby, and every commit then waits for that one machine. The night pg-replica-b reboots for a kernel patch, commits stop returning and Cartwheel stops accepting orders, because the primary was told to wait and it is waiting. ANY 1 (replica_a, replica_b) asks for an acknowledgement from either one, so a single host can be absent with no effect on the primary at all. FIRST 1 (replica_b, replica_a) survives the same failure by promoting the spare, and expresses a preference at the same time. Both of those name two standbys and require one.
'replica_b'one standby namedANY 1 (replica_a, replica_b)quorum-basedFIRST 1 (replica_b, replica_a)priority-basedSELECT application_name, sync_state, sync_priority, replay_lag FROM pg_stat_replication ORDER BY sync_priority; application_name | sync_state | sync_priority | replay_lag ------------------+------------+---------------+------------- replica_a | quorum | 1 | 00:41:12.0 replica_b | quorum | 1 | 00:00:00.4
sync_state is where a configuration mistake becomes visible: async means this standby is not part of the guarantee at all, quorum means it is a candidate under ANY, and under FIRST the chosen ones read sync while the spares read potential. A standby listed by a name that does not match its application_name shows up as async, and the site then has exactly the durability it had before somebody edited the file. One more property is worth knowing before an incident teaches it: a commit that is waiting has already been flushed locally, so the wait withholds the acknowledgement rather than holding the transaction open. If the primary restarts while commits are waiting, those transactions are marked fully committed once it recovers. Emptying synchronous_standby_names and reloading is therefore a legitimate emergency lever: the queued commits complete at once, and everything committed between the reload and the fix carries the asynchronous guarantee.
Choosing per Transaction
synchronous_commit is settable per transaction with SET LOCAL, per session, and per role with ALTER ROLE, which is why a single global answer is almost always the wrong one. Cartwheel's delivery_events ingest runs at about 46 rows a second all day and would pay the round trip 4 million times a day to protect data whose loss nobody could detect. The payment write happens a few thousand times a day and protects money. Setting one value for both means either overpaying on the first or underprotecting the second, and there is no reason to choose. The practical consequence is that Cartwheel's recovery point objective is not one number for the database; it is one number per class of write, and that table belongs next to the staleness table from the previous topic.
Latency, Distance, and Physics
A synchronous standby in the same availability zone adds a fraction of a millisecond to a commit, small enough that most workloads cannot measure it against the local flush they were already paying for. Across a region it is typically a millisecond or two. Across a continent it stops being an engineering number and becomes a physical one: light travels through fibre at roughly 200 kilometres per millisecond, real cable routes are considerably longer than the straight line, and a 4,000-kilometre path therefore costs at least 40 milliseconds of round trip before any software runs. That is added to every commit that waits, and no amount of tuning removes it.
So cross-region synchronous replication is a decision about how slow every protected write may be, not a checkbox that makes the data safer. Measure it in your own topology rather than in a blog post's: write_lag and flush_lag in pg_stat_replication exist precisely as gauges for what remote_write and on would cost you, and replay_lag for remote_apply. Read those three columns on the standby you are considering, for a week including a Saturday, before promising anybody a latency figure.
What It Still Does Not Give
Synchronous replication guarantees that an acknowledged transaction survives the loss of the primary host. It does nothing else, and the list of things people assume it covers is long. It does not make failover automatic; lossless and automatic are separate properties, and the next-but-one topic is about the second one. It does not protect against a migration deleting 90,000 rows, and in fact reproduces that delete with more urgency than asynchronous replication would; backups and point-in-time recovery are still the only answer to a statement that did what it was told. It does not remove the need for a rehearsed restore. And a quorum bounds the failure mode without removing it: with ANY 1 (replica_a, replica_b), both standbys being down still stops commits, and the written response to that is the same reload of an emptied synchronous_standby_names.
Asynchronous replication costs no commit latency, depends on no replica's health, and sets a recovery point objective equal to whatever the lag happened to be when the primary died. Correct for the large majority of systems, provided the lag is actually monitored and the tail is known.
Synchronous replication gives zero loss of acknowledged transactions on host failure, a commit latency floor set by the network, and a hard dependency on a quorum of standbys staying alive. The dependency is the part that surprises people, because it converts a replica's maintenance window into a primary-side outage.
How to decide: by what the rows are, not by what the database is. Writes whose loss is money or a legal record get the strictest setting available; everything else stays asynchronous and cheap. Because the level is per transaction, this is a choice you make several times rather than once.
- Naming exactly one standby in
synchronous_standby_names— the site stops accepting writes the night that machine reboots, and the cause looks like a database failure rather than a configuration choice. - Listing a standby under a name that does not match its
application_name—sync_statereadsasync, nothing waits for anything, and the guarantee exists only in the config file. - Enabling synchronous replication across regions and then reporting the added 40 milliseconds per commit as a Postgres performance problem.
- Pointing
remote_applyat the analytics replica — commit latency on the primary is now coupled to how busy that replica's replay process is. - Turning it on globally when only the payment path needed it, and paying a round trip on four million
delivery_eventsinserts a day. - Believing synchronous replication makes failover automatic — it makes it lossless, which is a different property and needs different machinery.
- Use
ANY 1 (…)with at least two candidate standbys named, so no single machine's absence can block a commit. - Set the global
synchronous_commitconservatively and raise it per transaction class, with the strictest level on the writes whose loss is unacceptable. - Verify the configuration through
sync_stateinpg_stat_replicationrather than through the config file, since a name mismatch is silent. - Measure the added commit latency in your own topology from
write_lag,flush_lagandreplay_lagbefore promising a latency number to anyone. - Keep the synchronous standby free of user queries, because
remote_applymakes its replay backlog your commit latency. - Alert on standby health as a production dependency now that commits rely on it, and write down the procedure for emptying
synchronous_standby_namesunder duress.
Knowledge Check
What is the recovery point objective of an unplanned failover under asynchronous replication?
- The replica's replication lag at the instant the primary stopped
- The interval since the primary's last completed checkpoint
- One WAL segment, since segments are shipped only when they fill
- The interval since the last segment reached the WAL archive
Which remote level protects a committed transaction against an operating-system crash on the standby?
- The
remote_writelevel, which confirms the standby has received the record - The
onlevel, which waits for the standby's flush to durable storage - The
locallevel, which waits only for the primary's own flush to disk - The
offlevel, which lets the WAL writer flush the record shortly after
Why is ANY 1 (replica_a, replica_b) a safer setting than naming replica_b alone?
- It halves commit latency by accepting the faster of the two replies
- Either standby can satisfy the commit, so one may be down without blocking writes
- It selects the more current standby automatically when a failover happens
- It stops either standby from retaining WAL on the primary while it is down
A commit has been waiting 90 seconds for a synchronous standby that is down. What is true of that transaction?
- It has been rolled back locally and will be reported as a failure
- It is already flushed locally and will count as committed if the primary restarts
- It has released its locks and is holding only the client's acknowledgement
- It will be downgraded to local durability automatically after a timeout
Why is remote_apply a poor choice against the replica that also runs the analytics workload?
- A standby serving read-only queries cannot be a synchronous standby
- Commit latency on the primary becomes whatever that replica's replay is doing
- Enabling it silently turns off
hot_standby_feedbackon that replica - Long analytics queries there are cancelled every time a commit waits
You got correct