Almost every surprising thing PostgreSQL does traces back to one design decision: an UPDATE does
not modify a row, it writes a new version of it and leaves the old one behind. That is multiversion
concurrency control, and it buys readers that never block writers. It costs you dead rows, table
bloat, a background process whose job is to clean them up, and a 32-bit counter that will destroy
your database if that process ever stops keeping pace.
Process and memory architecture
The supervisor process (historically the postmaster, and still the postgres binary) listens
for connections and forks a new backend process for every connection. Client and backend then
talk directly, with no further involvement from the supervisor. Alongside the backends the cluster
runs a set of background processes: the checkpointer, the background writer, the WAL writer, the
autovacuum launcher and its workers, and (from 18) I/O workers.
The per-connection process model is why connection count is expensive in PostgreSQL and why connection pooling is a near-universal deployment requirement. That subject is out of scope here; see PgBouncer for pooling and Pgpool-II if you also need read/write splitting.
Memory splits into one shared region and a per-operation allocation, and confusing the two is the most common sizing error.
| Parameter | Default | Scope | Notes |
|---|---|---|---|
shared_buffers |
128MB |
Cluster-wide, fixed at start | Docs suggest ~25% of RAM on a dedicated server with 1GB+; more than 40% is unlikely to help |
work_mem |
4MB |
Per sort or hash node, per session | Base limit before spilling to temporary files |
hash_mem_multiplier |
2.0 |
Multiplies work_mem for hash nodes |
So a hash node’s real limit is 8MB at defaults |
maintenance_work_mem |
64MB |
Per maintenance operation | VACUUM, CREATE INDEX, ALTER TABLE ADD FOREIGN KEY |
autovacuum_work_mem |
-1 (use maintenance_work_mem) |
Per autovacuum worker | Set it if maintenance_work_mem is large |
effective_cache_size |
4GB |
Planner estimate only | Allocates nothing; influences index vs sequential scan choice |
temp_buffers |
8MB |
Per session | Temporary tables only |
max_connections |
typically 100 | Cluster-wide, fixed at start | Sizes shared memory |
work_mem is not a per-query budget. The documentation is explicit: a complex query may perform
several sorts and hashes at the same time, each generally allowed to use work_mem, and many
sessions may be doing this concurrently. Total memory used can therefore be many multiples of the
configured value. A plan with eight hash joins at work_mem = 64MB and hash_mem_multiplier = 2.0
can reach roughly 1GB in a single backend. Partitionwise joins and aggregates make this worse: the
documentation warns that the number of work_mem-restricted nodes grows linearly with the number of
partitions scanned, which is why enable_partitionwise_join and enable_partitionwise_aggregate
both default to off.
The safe pattern is a conservative global work_mem with per-role overrides for the handful of
reporting queries that need more:
ALTER ROLE analytics SET work_mem = '256MB';
maintenance_work_mem behaves differently. Only one such operation runs per session at a time, so
it can safely be much larger than work_mem. The caveat is autovacuum: up to
autovacuum_max_workers copies of it may be allocated concurrently. Set autovacuum_work_mem
separately if you raise maintenance_work_mem for index builds.
effective_cache_size allocates nothing at all. It tells the planner how much of the table it can
assume is already cached, which makes index scans look cheaper. On a dedicated database host, a
value in the region of shared buffers plus the OS page cache is reasonable; the 4GB default is
usually far too low on modern hardware and biases the planner towards sequential scans.
MVCC: xmin, xmax, dead tuples and bloat
Every row version carries hidden system columns. Two of them define visibility:
| Column | Meaning |
|---|---|
xmin |
Transaction ID that inserted this row version |
xmax |
Transaction ID that deleted it, or zero if not deleted |
cmin / cmax |
Command IDs within the inserting/deleting transaction |
ctid |
Physical location (block, offset) of this row version |
You can look at them directly:
SELECT xmin, xmax, ctid, * FROM accounts WHERE id = 42;
A transaction sees a row version if xmin committed and is in the past relative to its snapshot,
and xmax is either zero or belongs to a transaction that has not committed. A non-zero xmax on a
visible row is normal: usually the deleting transaction has not committed, or was rolled back.
This is why an UPDATE writes a new row version instead of modifying in place: the old version
must remain readable by any transaction whose snapshot predates the update. The old version becomes
a dead tuple once no snapshot can still see it. DELETE produces dead tuples the same way; it
only sets xmax.
Dead tuples that are not reclaimed are bloat: physical space in the table and its indexes that holds nothing useful. A table that is updated heavily and vacuumed rarely grows without bound even though its live row count is constant, and every sequential scan pays for the empty space.
Two mechanisms limit the damage. The first is heap-only tuples (HOT): if an update touches no
indexed column and there is room on the same page, the new version goes on that page with no new
index entries, and intermediate versions can be removed during ordinary operation including by
SELECTs. Lowering a table’s fillfactor increases the chance of a HOT update. BRIN, the only
summarising index in core, does not block HOT.
The second is VACUUM. It removes dead row versions from tables and indexes and marks the space
available for reuse, but it does not return that space to the operating system, except where
whole pages at the end of the table are free and an exclusive lock is readily available. Only
VACUUM FULL (or CLUSTER, or a table-rewriting ALTER TABLE) shrinks the file, and all of them
take an ACCESS EXCLUSIVE lock and need extra disk space roughly equal to the table size while they
run. Autovacuum never issues VACUUM FULL. The goal of routine vacuuming is not minimum size, it is
steady-state size: often enough that you never need VACUUM FULL.
Transaction ID wraparound
Transaction IDs are 32 bits. Normal XIDs are compared using modulo-2³² arithmetic, so for any given XID two billion others are “older” and two billion are “newer”. The space is a circle with no endpoint. A row version stays in the past for the next two billion transactions. Past that point it suddenly appears to be in the future, and becomes invisible. The data is still on disk; you just cannot read it.
The fix is freezing. VACUUM marks sufficiently old row versions frozen, which means they are
treated as older than every normal XID regardless of where the counter is. Since 9.4 this is a flag
bit and not a rewrite of xmin, so the original xmin is preserved for forensics. Every table
in every database must be vacuumed at least once every two billion transactions.
“Autovacuum to prevent wraparound” is what happens when the ordinary triggers have not fired in
time. Any table whose pg_class.relfrozenxid is older than autovacuum_freeze_max_age is vacuumed
even if autovacuum is turned off. That vacuum is aggressive: it visits every page that might
contain unfrozen XIDs, not just pages with dead tuples, so on a large cold table it is a full-table
read that appears from nowhere and cannot be cancelled without simply coming back.
| Parameter | Default | What it controls |
|---|---|---|
autovacuum_freeze_max_age |
200,000,000 xacts | Age of relfrozenxid that forces an anti-wraparound vacuum |
vacuum_freeze_min_age |
50,000,000 xacts | How old an XID must be before it is frozen |
vacuum_freeze_table_age |
150,000,000 xacts | Age at which a normal vacuum escalates to an aggressive scan; capped at 95% of autovacuum_freeze_max_age |
vacuum_failsafe_age |
1,600,000,000 xacts | Point at which vacuum drops cost delays and skips index vacuuming |
autovacuum_multixact_freeze_max_age |
400,000,000 multixacts | Same, for multixact IDs |
vacuum_max_eager_freeze_failure_rate |
0.03 (18+) |
Fraction of pages a normal vacuum may fail to freeze before giving up eager scanning |
Monitor the age directly. This is the single most valuable maintenance query on the page:
SELECT c.oid::regclass AS table_name,
greatest(age(c.relfrozenxid), age(t.relfrozenxid)) AS xid_age
FROM pg_class c
LEFT JOIN pg_class t ON c.reltoastrelid = t.oid
WHERE c.relkind IN ('r', 'm')
ORDER BY xid_age DESC
LIMIT 20;
SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY 2 DESC;
If freezing falls far enough behind, the server warns at forty million transactions from wraparound:
WARNING: database "mydb" must be vacuumed within 39985967 transactions
and at fewer than three million remaining it refuses to assign new XIDs:
ERROR: database is not accepting commands that assign new transaction IDs to avoid wraparound data loss in database "mydb"
Read-only transactions still work; anything that writes fails. The documented recovery is not single
user mode. Commit or roll back old prepared transactions found in pg_prepared_xacts, end
long-running transactions found via age(backend_xid) in pg_stat_activity, drop abandoned
replication slots with a large age(xmin) in pg_replication_slots, then run a plain database-wide
VACUUM as a superuser. Do not use VACUUM FULL here: it requires an XID and will fail, or in
superuser mode consume one and make things worse. Do not use VACUUM FREEZE either. It does more
work than is needed to restore service.
Note the three usual causes in that list. A forgotten replication slot, an idle-in-transaction session and an orphaned prepared transaction each hold back the xmin horizon, and stop vacuum from freezing anything however aggressively it is configured.
Autovacuum thresholds
Autovacuum consists of a persistent launcher that starts one worker per database every
autovacuum_naptime seconds, and the workers themselves. Worker processes do not count against
max_connections.
A table is vacuumed when the number of dead tuples exceeds:
vacuum threshold = Min(autovacuum_vacuum_max_threshold,
autovacuum_vacuum_threshold
+ autovacuum_vacuum_scale_factor * pg_class.reltuples)
| Parameter | Default | Notes |
|---|---|---|
autovacuum |
on |
track_counts must also be on |
autovacuum_naptime |
1min |
Per database; with N databases, one worker every naptime/N |
autovacuum_max_workers |
3 |
Runtime-adjustable from 18, up to autovacuum_worker_slots |
autovacuum_worker_slots |
typically 16 (18+) | Start-time only; ceiling for the above |
autovacuum_vacuum_threshold |
50 tuples | Base term |
autovacuum_vacuum_scale_factor |
0.2 |
20% of the table |
autovacuum_vacuum_max_threshold |
100,000,000 tuples (18+) | Cap; -1 disables the cap |
autovacuum_vacuum_insert_threshold |
1000 tuples | Insert-only tables; -1 disables |
autovacuum_vacuum_insert_scale_factor |
0.2 |
Of unfrozen pages |
autovacuum_analyze_threshold |
50 tuples | |
autovacuum_analyze_scale_factor |
0.1 |
10% of the table |
autovacuum_vacuum_cost_delay |
2ms |
-1 means use vacuum_cost_delay |
autovacuum_vacuum_cost_limit |
-1 (use vacuum_cost_limit, 200) |
Shared out across running workers |
The 20% scale factor is the problem. On a 10,000-row table it means vacuuming after about 2,050 dead
tuples, which is fine. On a 500 million row table it means waiting for 100 million dead tuples
before autovacuum even starts. By that point the vacuum is enormous, the table is badly bloated,
and query plans built on stale statistics are already wrong. The 0.1 analyze scale factor has the
same shape: 50 million modified rows before ANALYZE runs.
PostgreSQL 18 addresses the vacuum half of this with autovacuum_vacuum_max_threshold, which caps
the computed threshold at 100 million tuples by default. That is still a lot. On 14-17 there is no
cap at all, and on every version the analyze threshold remains uncapped. Override per table:
ALTER TABLE events SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_vacuum_threshold = 2000,
autovacuum_analyze_scale_factor = 0.005,
autovacuum_analyze_threshold = 2000,
autovacuum_vacuum_cost_delay = 2
);
The cost is more frequent vacuum I/O against your busiest table, which is nearly always the right
trade: the alternative is one very large vacuum instead of many small ones. Two further caveats:
autovacuum never processes partitioned tables themselves, only their partitions, so ANALYZE on the
parent must be scheduled manually; and it cannot touch temporary tables at all.
WAL, checkpoints and durability
Every change is written to the write-ahead log before the modified data page reaches disk. Recovery replays WAL from the last checkpoint. A checkpoint flushes dirty buffers and establishes the point from which replay begins.
| Parameter | Default | Notes |
|---|---|---|
wal_level |
replica |
minimal, replica, logical. logical is required for logical decoding and increases WAL volume |
fsync |
on |
Off risks unrecoverable corruption. Do not |
synchronous_commit |
on |
See below |
full_page_writes |
on |
Whole page written to WAL on first modification after a checkpoint |
wal_compression |
off |
pglz, lz4, zstd; compresses full-page images only |
wal_buffers |
-1 (1/32 of shared_buffers, 64kB - 16MB) |
|
checkpoint_timeout |
5min |
Range 30s to 1 day |
max_wal_size |
1GB |
Soft limit; a checkpoint is triggered when exceeded |
min_wal_size |
80MB |
Below this, segments are recycled rather than deleted |
checkpoint_completion_target |
0.9 |
Spread of checkpoint I/O across the interval |
checkpoint_warning |
30s |
Logs when WAL-triggered checkpoints are this close together |
wal_writer_delay |
200ms |
Bounds the synchronous_commit = off exposure window |
full_page_writes exists because an OS crash during a page write can leave a torn page - part old,
part new - and row-level WAL records cannot repair that. Writing the full image after each
checkpoint guarantees replay can reconstruct it. The cost is WAL volume, concentrated in a burst
immediately after every checkpoint. The documented way to reduce it is to checkpoint less often, by
raising checkpoint_timeout and max_wal_size; the trade is longer crash recovery. Turning
full_page_writes off carries risks similar to disabling fsync, only smaller. It is not a tuning
knob.
synchronous_commit is the real durability dial, and unlike fsync it does not risk an inconsistent
database. In off mode a crash may lose recently acknowledged transactions, but the database ends up
exactly as if they had been cleanly aborted. The maximum exposure is three times wal_writer_delay.
| Value | Commit returns after | Loses on failure of |
|---|---|---|
off |
Nothing waited for | Primary crash, up to 3 × wal_writer_delay of commits |
local |
Local WAL flush | Primary storage |
remote_write |
Standby has written to its file system | Standby OS crash |
on (default) |
Standby has flushed to durable storage | Both primary and standby storage |
remote_apply |
Standby has applied it and it is visible to queries there | Both, and only then |
If synchronous_standby_names is empty, only on and off differ; the other three behave like
on. remote_apply is the setting that makes a read on a standby guaranteed to see a write that has
just been acknowledged, and the documentation is blunt that it causes much larger commit delays
because it waits for replay. It is set per transaction, which is the sensible way to use it:
BEGIN;
SET LOCAL synchronous_commit = 'remote_apply';
UPDATE accounts SET balance = balance - 100 WHERE id = 42;
COMMIT;
Index types
B-tree is the default and correct for most cases. The others are not general-purpose replacements; each answers a specific class of query.
| Type | Operators / use | Reach for it when |
|---|---|---|
| B-tree | < <= = >= >, BETWEEN, IN, IS NULL, prefix LIKE 'foo%' |
Default. Also returns rows in sorted order |
| Hash | = only |
Equality on large values where a 32-bit hash is much smaller than a B-tree key |
| GiST | Varies by operator class; geometric containment/overlap, nearest-neighbour ORDER BY location <-> point |
Geometric and range types, exclusion constraints, KNN search |
| SP-GiST | Varies; quadtrees, k-d trees, radix tries | Non-balanced partitioned structures - points, text prefixes |
| GIN | Containment over composite values: @> <@ = && on arrays, jsonb, full text |
A column whose values contain many searchable components |
| BRIN | < <= = >= > over block-range summaries |
Very large tables where values correlate with physical row order, e.g. append-only timestamps |
BRIN is the one with a hard prerequisite: it stores summaries per range of physical blocks, so it is only effective when the column correlates with the table’s physical order. On an append-only event log keyed by insertion time it is far smaller than the equivalent B-tree; on a randomly ordered column it is close to useless.
Prefix-matching with B-tree needs a text-pattern operator class outside the C locale:
CREATE INDEX idx_users_email_prefix ON users (email text_pattern_ops);
CREATE INDEX idx_docs_search ON docs USING GIN (to_tsvector('english', body));
CREATE INDEX idx_events_ts ON events USING BRIN (created_at);
Replication
Physical (streaming) replication ships WAL byte for byte. The standby is an exact block-level
copy of the primary, all databases, all objects, read-only. It needs wal_level = replica or
higher.
Logical replication decodes WAL into row changes and applies them on a subscriber, per
publication. It needs wal_level = logical, which increases WAL volume, particularly with
REPLICA IDENTITY FULL. Its restrictions are the deciding factor: DDL is not replicated, sequence
data is not replicated, large objects are not replicated, and only tables can be published, not views, materialised
views or foreign tables. A schema change on the publisher that the subscriber has
not received will error the apply worker until you fix it by hand.
| Parameter | Default | Notes |
|---|---|---|
max_wal_senders |
10 |
0 disables replication |
max_replication_slots |
10 |
Start-time only; lowering below the number of existing slots prevents startup |
wal_keep_size |
0 |
Extra WAL retained in pg_wal for standbys, in MB |
max_slot_wal_keep_size |
-1 (unlimited) |
The disk-fill hazard - see below |
idle_replication_slot_timeout |
0 (disabled, 18+) |
Invalidates slots inactive this long, at checkpoint |
hot_standby_feedback |
off |
Stops query cancellation on the standby, at the cost of bloat on the primary |
max_standby_streaming_delay |
30s |
How long the standby delays WAL apply for conflicting queries; -1 waits forever |
wal_receiver_timeout |
60s |
Detects a dead primary |
A replication slot makes the primary retain WAL until the consumer has confirmed it. That is what
stops a standby from falling irrecoverably behind. It is also the standard mechanism for taking a
PostgreSQL server down. With max_slot_wal_keep_size = -1, the default, a slot may retain an
unlimited amount of WAL. A standby that has been switched off for a fortnight, or a logical
subscriber whose apply worker is stuck on a schema mismatch, will fill pg_wal until the filesystem
is full and the primary stops. The same slot also holds back xmin, so vacuum cannot freeze and
wraparound age climbs at the same time.
Set a bound, and monitor:
ALTER SYSTEM SET max_slot_wal_keep_size = '64GB';
SELECT pg_reload_conf();
SELECT slot_name, slot_type, active, wal_status, safe_wal_size,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained,
age(xmin) AS xmin_age, inactive_since
FROM pg_replication_slots
ORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC;
The trade-off is explicit: bounding retention means a slot can be invalidated and its consumer will
need rebuilding, instead of the primary going down. On 18, idle_replication_slot_timeout gives a
time-based version of the same protection, evaluated at checkpoint time.
hot_standby_feedback deserves a warning of its own. It stops the standby cancelling long read
queries by telling the primary not to clean up rows the standby still needs. Bloat on
the primary is then controlled by query duration on the replica. The documentation notes it “can cause
database bloat on the primary for some workloads”. Prefer raising max_standby_streaming_delay when
you can tolerate replication lag instead.
Diagnostics
Three views answer most questions.
pg_stat_activity, one row per backend, is the first thing to look at:
SELECT pid, state, wait_event_type, wait_event,
now() - xact_start AS xact_age,
now() - state_change AS state_age,
age(backend_xid) AS xid_age,
left(query, 80) AS query
FROM pg_stat_activity
WHERE backend_type = 'client backend' AND state <> 'idle'
ORDER BY xact_start;
The state to hunt for is idle in transaction. A backend in that state holds its snapshot open, so
it pins the xmin horizon and blocks vacuum cluster-wide. A large age(backend_xid) on such a session
is the direct cause of bloat you cannot vacuum away. idle_in_transaction_session_timeout is the
fix; transaction_timeout, added in 17, bounds total transaction duration regardless of state.
pg_stat_user_tables shows where the bloat is and whether autovacuum is keeping up:
SELECT relname, n_live_tup, n_dead_tup,
round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
n_mod_since_analyze, last_autovacuum, last_autoanalyze, autovacuum_count
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;
n_dead_tup climbing while last_autovacuum stays old means autovacuum is either not triggering
(thresholds too high for the table size) or not finishing (too few workers, or throttled by the cost
delay). n_tup_hot_upd versus n_tup_upd in the same view tells you what fraction of updates avoided
index maintenance; a low ratio on a heavily updated table is an argument for lowering fillfactor or
dropping an index. Note these counters are eventually consistent, flushed no more than once per
second, and are reset by an unclean shutdown.
EXPLAIN (ANALYZE, BUFFERS) reports what the query actually did:
EXPLAIN (ANALYZE, BUFFERS, SETTINGS) SELECT * FROM events WHERE created_at > now() - interval '1 day';
ANALYZE executes the statement, so wrap it in a transaction you roll back if it writes. Compare
estimated rows against actual: a large discrepancy means stale or insufficient statistics, and
points at ANALYZE or default_statistics_target (default 100) rather than at the plan. BUFFERS
separates shared hits from reads, which tells you whether a slow node is CPU-bound or waiting on I/O.
A Sort Method line reporting a disk sort means that node exceeded work_mem; log_temp_files
records the same spills cluster-wide. From 18, BUFFERS is included in EXPLAIN ANALYZE
automatically.
Version-specific guides
- PostgreSQL 19 - the next major release, currently in beta: REPACK, the WAIT command, logical replication of sequences, parallel and prioritised autovacuum, plan advice, and the full upgrade-breakers list.
What changed, and in which release
| Release | Date | Change that affects this page |
|---|---|---|
| 17 | Sep 2024 | Vacuum’s new dead-tuple store removed the silent 1GB cap on maintenance_work_mem/autovacuum_work_mem; vacuum_buffer_usage_limit default raised to 2MB; old_snapshot_threshold removed; MAINTAIN privilege and pg_maintain role added; logical slot failover (sync_replication_slots, pg_sync_replication_slots()); transaction_timeout and allow_alter_system added; pg_stat_progress_vacuum.max_dead_tuples renamed to max_dead_tuple_bytes |
| 18 | Sep 2025 | initdb enables data checksums by default; asynchronous I/O subsystem (io_method, default worker) with effective_io_concurrency and maintenance_io_concurrency defaults raised to 16; autovacuum_vacuum_max_threshold (100M) caps the scale-factor threshold; autovacuum_worker_slots makes autovacuum_max_workers runtime-adjustable; normal vacuums eagerly freeze all-visible pages (vacuum_max_eager_freeze_failure_rate, 0.03); vacuum_truncate added as a server variable; idle_replication_slot_timeout added; EXPLAIN ANALYZE includes BUFFERS by default; B-tree skip scans; MD5 password authentication deprecated |
Three of these bite on upgrade, not on adoption. Data checksums on by default means
pg_upgrade refuses to run between clusters whose checksum settings differ; use
initdb --no-data-checksums to match an old cluster. Eager freezing in 18 changes the I/O profile of
ordinary vacuums on large mostly-static tables: more work per normal vacuum, less work at the next
aggressive one. And autovacuum_vacuum_max_threshold will make autovacuum fire more often on very
large tables than it did on 17, which is the intent, but it is a behaviour change on a table you may
have tuned by hand.
What PostgreSQL is not
It is a single-primary database. Physical replicas are read-only, and promotion is a manual operation unless something else is deciding: Patroni with etcd, Consul or ZooKeeper is the usual answer, with HAProxy or keepalived owning the virtual IP. Nothing in core does automatic failover.
It does not pool connections, and its per-connection process model makes that omission expensive. Put PgBouncer in front of it in transaction mode, or Pgpool-II if you also want SELECTs routed to replicas. Neither belongs in the database itself.
And it does not shard. Logical replication moves rows between clusters; it does not partition a workload across them. That is an application-level or extension-level decision.
PostgreSQL release support
5 supported| Release | Latest | Released | End of life | Status |
|---|---|---|---|---|
18 | 18.6 | Sept 2025 | Nov 2030 | Supported |
17 | 17.11 | Sept 2024 | Nov 2029 | Supported |
16 | 16.15 | Sept 2023 | Nov 2028 | Supported |
15 | 15.19 | Oct 2022 | Nov 2027 | Supported |
14 | 14.24 | Sept 2021 | Nov 2026 | Supported |
13 | 13.23 | Sept 2020 | Nov 2025 | End of life |
12 | 12.22 | Oct 2019 | Nov 2024 | End of life |
11 | 11.22 | Oct 2018 | Nov 2023 | End of life |
Data as of 2026-10-01 from endoflife.date. Refresh with node scripts/fetch-versions.mjs.
What to watch
Specific things that would change what this page concludes.
- PostgreSQL 14 reaches end of life, removing the oldest supported branch12 November 2026
- Whether the asynchronous I/O subsystem introduced in 18 changes the standard tuning advice for effective_io_concurrency
What automatic failover actually commits you to
AnalysisIf: You adopt automatic failover for PostgreSQL
The intended result: recovery in seconds rather than however long it takes to wake someone.
A network blip shorter than the lease TTL now costs you a promotion. The timing parameters are a direct trade between recovery speed and spurious failover.
etcd is now in the availability path of the database. Its disk latency requirements are strict, and losing quorum demotes the primary by design.
Connection strings, pooler configuration and retry logic all have to assume the write endpoint changes. Failover that the application cannot survive is not availability.
Second and third order effects are reasoned from the mechanics described on this page, not measured. Treat them as consequences to plan for rather than certainties.
Where PostgreSQL sits
How this component relates to the rest of the stack. Items without a link are not covered here yet.