An UPDATE statement returns in two milliseconds. From where you're sitting, that's the whole story: the row changed, the client got an acknowledgment, done. Underneath, Postgres just did five separate jobs to make that write durable, recoverable, reusable, and still fast for the next query to plan correctly. Most of the time you never see any of them. Then a dashboard shows periodic latency spikes with no slow query in sight, or pg_stat_activity shows a backend "stuck" on something with a name you've never had to learn, and you're suddenly debugging a part of Postgres that has been running the entire time.
This is a walkthrough of that background layer: the Write-Ahead Log, checkpoints, the wait events that make both of them visible, and the vacuum/analyze cycle that cleans up after every write and keeps the query planner honest.
WAL: the black box that makes crash recovery possible
Postgres never writes a change directly to a table's data file first. It writes a record of the change to the Write-Ahead Log (WAL) first, and only later, asynchronously, does the actual data file get updated. This is the same idea as an aircraft's flight data recorder: it doesn't prevent anything from going wrong, but if the plane goes down, the black box is what lets you reconstruct exactly what happened, in order, right up to the last recorded moment.
If Postgres crashes between "WAL record written" and "data file updated," the data file is allowed to be behind. On restart, Postgres replays the WAL from the last confirmed checkpoint forward and reconstructs every change that was durably logged but not yet applied to the heap. That's the entire crash-recovery guarantee, and it's also the entire replication guarantee: a replica is, at its core, a process continuously replaying WAL records shipped from the primary.
Why this matters in production: synchronous_commit controls whether a transaction waits for its WAL record to actually hit disk before returning success to the client. Turn it off (or set it to a weaker level like local) and writes get faster, because you're no longer waiting on an fsync. You're also now willing to lose the last few transactions if the server crashes before that WAL hits disk. That's a real trade-off teams make deliberately for high-throughput, loss-tolerant write paths, and a real trade-off teams make by accident when they copy a config from a benchmark blog post.
Checkpoints: the point WAL gets allowed to forget the past
WAL can't grow forever, and replaying an unbounded WAL after a crash would make recovery take unbounded time. A checkpoint is Postgres periodically flushing every dirty page sitting in shared buffers out to the actual data files, so that everything the WAL described up to that point is now durably reflected on disk. Once a checkpoint completes, the WAL segments before it are no longer needed for crash recovery and can be recycled or removed (subject to what replication and archiving still need them for).
Checkpoints run on a schedule controlled by two settings: checkpoint_timeout (time-based, five minutes by default) and max_wal_size (a soft cap on how much WAL can accumulate before a checkpoint is forced early). Whichever threshold is hit first triggers the checkpoint.
Why this matters in production: a checkpoint means writing out every dirty page at once, which is a burst of I/O. If that burst happens all at once, you get the classic "checkpoint spike," periodic latency jumps that correlate with wall-clock time, not query volume. checkpoint_completion_target exists specifically to spread that I/O out over the checkpoint interval instead of dumping it all at once. If your latency graphs show a sawtooth pattern with a period matching your checkpoint_timeout, that's usually the first setting worth checking, not the query itself.
Wait events: the window into both of the above
pg_stat_activity has two columns, wait_event_type and wait_event, that tell you what a backend is actually blocked on right now, if it's blocked on anything. This is the diagnostic surface that turns "the query is slow" into "the query is waiting on something specific":
The wait_event_type column groups events into categories: Lock (waiting on a row, table, or advisory lock held by another transaction), LWLock (waiting on an internal lightweight lock protecting shared memory structures), IO (waiting on an actual disk read or write), BufferPin, IPC, Timeout, and a few others. A backend showing IO wait events tied to WAL activity, or an LWLock wait tied to the WAL insert path, is a backend that's been caught behind the exact mechanism described above: it's trying to get its change durably logged and something (usually disk throughput, sometimes a checkpoint mid-flush) is making it wait its turn.
Why this matters in production: the specific wait event names attached to WAL and checkpoint activity have changed across major Postgres versions as the I/O statistics system was reworked, so don't memorize a table of exact strings from a blog post (including this one) and assume it matches your version. Instead, learn the query above, run it against your own instance during a slow period, and read whatever wait_event_type/wait_event pair comes back against the current docs for your version. That habit outlives any specific release.
Vacuum: cleaning up after MVCC, not just reclaiming disk
Postgres never overwrites a row in place on UPDATE or DELETE. MVCC (multi-version concurrency control) means an UPDATE writes a new row version and marks the old one dead, and a DELETE just marks a row dead, because other transactions that started before yours might still need to see the old version. Those dead row versions don't disappear on their own. VACUUM is the process that finds them, confirms no transaction still needs them, and marks that space reusable by future inserts on the same table.
Plain VACUUM does not shrink the file on disk (only VACUUM FULL does, by rewriting the whole table, which takes an exclusive lock). It reclaims space within the existing file for future rows to reuse. autovacuum runs this automatically, table by table, once the number of dead tuples crosses a threshold (autovacuum_vacuum_threshold plus a percentage of the table's row count, autovacuum_vacuum_scale_factor, which defaults to 20%). Vacuum also has a second job unrelated to space at all: it's what prevents transaction ID wraparound, a much rarer but far more serious failure mode where an un-vacuumed table's transaction ID counter runs out of room entirely.
Why this matters in production: vacuum generates its own WAL records, since it's modifying pages, so a large autovacuum run on a big table shows up as real write volume, not a free background chore. It also competes for the same I/O bandwidth a checkpoint is using, which is why an aggressive autovacuum kicking off right as a checkpoint is flushing dirty pages is a common, specific cause of a latency spike that doesn't correlate with any query change. Database Efficiency 101 goes deeper on the bloat side of this; the piece worth adding here is that vacuum isn't an isolated background job, it's sharing the same WAL and I/O path as everything above.
ANALYZE: the step that keeps the planner honest
ANALYZE is a separate operation from vacuum, even though autovacuum triggers both and people often say "autovacuum" to mean either. ANALYZE samples a table's rows and updates the planner's statistics (row counts, most common values, distribution histograms) stored in pg_statistic. The query planner uses those statistics, not a live count, to estimate how many rows a WHERE clause will match and decide whether a sequential scan, index scan, or bitmap heap scan will be cheapest.
Stale statistics don't cause a query to return wrong results. They cause the planner to misjudge how many rows it's dealing with, and a bad row-count estimate is one of the most common root causes behind "this query used to be fast and now it isn't," with no schema change and no data corruption involved, just a planner making a good decision on outdated information. autovacuum_analyze_threshold and autovacuum_analyze_scale_factor (10% by default) control when this runs automatically, similarly to vacuum's thresholds, and an insert-heavy, delete-free table (an append-only events table, for instance) still needs ANALYZE on a schedule even though it may rarely need vacuuming for dead tuples.
Why this matters in production: after a large bulk load, either via a migration or an initial data import, running ANALYZE explicitly before serving real traffic is worth the few seconds it costs. Waiting for autovacuum to notice on its own schedule means the planner may make its first few hours of decisions on statistics from before the table had any real data in it.
Putting the five back together
None of these are separate systems bolted onto Postgres. They're one pipeline: WAL makes every write durable and replicable, checkpoints periodically settle that WAL's guarantees into the actual data files and bound how long crash recovery takes, wait events are the live diagnostic surface showing you when a backend is stuck somewhere in that path, vacuum reclaims the space MVCC leaves behind and prevents wraparound, and ANALYZE keeps the planner's model of your data close enough to reality to keep picking good plans. A slow query is sometimes a bad query. Just as often, in a system that's been running fine for months, it's one of these five processes falling behind, and pg_stat_activity is the first place that tells you which one.