The graph that finally made me care about the background writer was a latency chart from a Tuesday morning. Every day at 09:00 our import job pushed roughly two million rows a minute into the orders pipeline, and every day at 09:04 the API's p99 latency climbed from about 60 milliseconds to just over 400. pg_stat_activity during the burst showed the same wall every time: dozens of backends in IO waits on DataFileWrite and LWLock waits on buffer mapping locks — the modern name for what older versions called BufferAlloc — while the database's write path was otherwise idle. Then, every twelve minutes or so, the checkpointer woke up and flushed close to 25 gigabytes of dirty buffers in a 90-second spike that saturated the volume's write throughput and made everything worse at once. The databases involved were healthy by every classic health check. The problem was who was writing the dirty pages, and when.
The instance had 32 GB of shared_buffers and completely stock bgwriter settings, which is the default posture of nearly every cluster I get handed. What follows is the mechanism, the knobs, the counters that tell you which writer is losing the race, and the values I now start from.
Why are there three writers of dirty buffers?
Because a dirty page leaves shared_buffers through whichever of three writers reaches it first: the backend that needs the buffer slot, the background writer doing its periodic rounds, or the checkpointer at checkpoint time. The backend path is the one that hurts. When a query needs a free buffer and the clock sweep hands it a victim page that is dirty, the backend has no choice — it must write that page to storage itself, holding the buffer's content lock, before it can read its own data into the slot. Your SELECT just inherited a synchronous disk write, and its latency now includes someone else's flush. The bgwriter exists precisely to prevent that: between rounds it scans ahead of the allocator, writing out pages that look likely to become victims, so backends usually find clean buffers waiting. The checkpointer is the third writer, and its job is not housekeeping but crash recovery — it flushes all dirty buffers to establish a redo point, as the checkpoint tuning notes walk through. The tuning goal is not to eliminate any of the three; it is to shift the steady-state share onto the bgwriter, because backend writes land in your latency path and checkpoint writes land in spikes, while bgwriter writes are small, continuous, and off the critical path.
What do the four bgwriter knobs actually control?
They control how often the bgwriter wakes, how much it may write per round, how it guesses demand, and when it forces writeback to the operating system. bgwriter_delay is the sleep between rounds, default 200 milliseconds. bgwriter_lru_maxpages is the hard cap on buffers written per round, default 100 — and at 8 kilobytes a page that caps the bgwriter at 800 KB per round, which even with zero delay is a ceiling of about 4 MB a second. On a cluster dirtying hundreds of megabytes per second during a burst, the stock bgwriter is mathematically incapable of keeping up, and that is not a tuning failure, it is arithmetic. bgwriter_lru_multiplier, default 2.0, scales the demand estimate: the bgwriter looks at how many buffers backends recently allocated and aims to clean that many times the multiplier ahead of them. The honest note is that the multiplier rarely drives anything in practice, because lru_maxpages binds first — I have changed the multiplier on a live system and watched exactly nothing happen, then raised maxpages and watched everything change. bgwriter_flush_after, default 512 KB, makes the bgwriter issue an explicit writeback to the OS after writing that many bytes, so the kernel's page cache does not dump a giant dirty pile at the next fsync; on kernels with aggressive writeback it smooths things, on some stacks it adds nothing, and it is the knob I touch last.
How do you read pg_stat_bgwriter to see who is writing?
You compare three counters and one warning flag, as rates over an interval, not as lifetime totals. buffers_checkpoint is pages written by the checkpointer, buffers_clean is pages written by the bgwriter, and buffers_backend is pages written by backends doing their own flushes — the counter you want trending toward zero. maxwritten_clean is the flag: it counts rounds where the bgwriter stopped early because it hit bgwriter_lru_maxpages, and any value climbing steadily means the bgwriter wanted to do more work than you allowed it. Since PostgreSQL 17 the checkpointer's columns moved into a separate pg_stat_checkpointer view, so on newer versions query both; on 16 and earlier it is all in pg_stat_bgwriter.
SELECT
buffers_checkpoint,
buffers_clean,
buffers_backend,
maxwritten_clean,
buffers_alloc,
round(100.0 * buffers_backend /
NULLIF(buffers_checkpoint + buffers_clean + buffers_backend, 0), 1)
AS backend_write_pct,
stats_reset
FROM pg_stat_bgwriter;
Take two samples ten minutes apart during your busy window and subtract; the lifetime numbers lie because they include last year's idle Sundays. On our cluster, one bad day sampled this way showed backends writing 31 percent of all flushed pages during the import window, buffers_clean contributing barely two percent of the total, and maxwritten_clean climbing on essentially every round — the bgwriter was running into its own ceiling hundreds of times an hour while backends paid for it in query latency. After the change below, the backend share fell under four percent and buffers_clean became the majority writer. buffers_backend_fsync is worth a glance too: a nonzero rate means backends are issuing their own fsyncs, which almost always points at something bypassing shared_buffers entirely rather than a bgwriter problem.
How does bgwriter tuning interact with checkpoint tuning?
The bgwriter and the checkpointer are flushing from the same pool of dirty buffers, so every page the bgwriter writes early is a page the checkpointer never has to — tuning one moves work off the other. That is the whole strategy against checkpoint IO spikes: spread the writes the checkpoint would otherwise dump in a burst across the minutes leading up to it. Our 25-gigabyte, 90-second checkpoint flushes became a low, flat write band because most of those pages had already left shared_buffers through the bgwriter before the checkpoint asked. Two honest interactions to keep in your head. First, full_page_writes: the first modification of a page after each checkpoint is logged whole to WAL regardless of who flushed it, so bgwriter work does not reduce WAL volume — the WAL monitoring notes cover that side of the ledger, and if your real bottleneck is WAL, bgwriter tuning will not save you. Second, write amplification: a page the bgwriter flushes at minute two that gets dirtied again at minute three must be written again at the next checkpoint, so an over-eager bgwriter on a hot, repeatedly-updated buffer set burns IO writing the same pages repeatedly. Checkpoint spacing matters here too — a long checkpoint_completion_target gives the bgwriter room to work between spikes, while a short timeout with a small max_wal_size creates checkpoints so frequent that no bgwriter setting can smooth them. Tune them as a pair: completion_target 0.9 and a sane max_wal_size first, then bgwriter to absorb what remains.
What are sane starting values for the bgwriter?
On a write-heavy cluster with fast storage I start here, and every one of these is a reload-time parameter, so pg_reload_conf applies them with no restart:
ALTER SYSTEM SET bgwriter_delay = '50ms';
ALTER SYSTEM SET bgwriter_lru_maxpages = 1000;
ALTER SYSTEM SET bgwriter_lru_multiplier = 2.0;
SELECT pg_reload_conf();
The reasoning: 50 milliseconds wakes the bgwriter four times as often as the default — the CPU cost of an idle wake-up is negligible, so the delay is nearly free to drop, and I have gone to 10 milliseconds on truly write-dominated NVMe clusters without being able to measure the overhead. 1000 pages per round raises the ceiling from 800 KB to 8 MB per round, which at four rounds a second is up to 32 MB a second of background flushing — still modest next to burst dirty rates, but enough to stay ahead of the allocator most of the time. The multiplier I leave at 2.0 because, as above, it is maxpages that binds. bgwriter_flush_after I leave at the default unless latency histograms point at kernel writeback stalls, in which case the fix is usually checkpoint_flush_after and OS-level writeback tuning, not the bgwriter copy of the knob. The cost sheet, honestly stated: more aggressive flushing adds background write IO you were previously paying later, and on spinning disks that competes with reads; it can double-write pages that churn between rounds; and if your workload never dirties buffers faster than backends can absorb, you will have tuned nothing. The defaults are timid but safe — a stock bgwriter never made anything worse, it just doesn't help much — so change values in steps, sample pg_stat_bgwriter deltas before and after, and let maxwritten_clean and the backend share tell you whether to keep going.
Watching the writer mix with MonPG
Everything in this article is a counter, which means none of it requires a war story to detect. The buffers_backend share of total flushed pages is the single number that would have caught our 09:00 burst problem a year earlier; maxwritten_clean climbing is the early warning that the bgwriter is throttled; and checkpoint write duration graphed against time shows whether your flushes are a band or a spike. MonPG graphs exactly these series — the pg_stat_bgwriter writer mix, maxwritten_clean, checkpoint timing, and backend wait events — as part of its PostgreSQL monitoring, so the regression shows up on a dashboard during the first burst instead of in a postmortem after the fortieth. Tune the pair of writers once, then let the counters argue with anyone who wants to set it back to defaults.