The comment at the top of async.c says that notify_buffers “can be varied without affecting anything but performance.” I went looking for the performance. The most I found was 10% of throughput, and I had to send more than 100MB of notifications a second to find it.
This is one of the seven SLRU cache sizes that PostgreSQL 17 made configurable; the commit_timestamp_buffers post has the tour of what an SLRU is. This one caches pages of the LISTEN/NOTIFY queue, the thing whose length max_notify_queue_pages limits. The default is 16, which is also the minimum. The value counts 8kB buffers, so the default cache is 128kB, and unlike max_notify_queue_pages it accepts units: notify_buffers = '2MB' is 256 buffers. The maximum is 131072 (1GB), the value has to be a multiple of 16, and the context is postmaster, so every change costs a restart. It does not scale with shared_buffers. On PostgreSQL 14 through 16 the size is 8, and it lives in a header file.
A queue that should be empty
A transaction that sent notifications appends them to the page at the head of the queue when it commits. Each listening backend keeps a read position, and when it is signaled it reads from that position up to the head. On a healthy system every listener stays close to the head, and those few pages are everything the cache has to hold.
Pages that fall out of the cache are written to files in pg_notify, with very little ceremony. Those files are never fsynced, checkpoints ignore them, and the directory is emptied every time the server starts. A miss in this cache is therefore almost always a copy out of the operating system’s page cache.
On 18.6 I ran eight clients sending 200-byte notifications as fast as they could, about 22,000 a second, with a listener connected. blks_read in the notify row of pg_stat_slru stayed at zero at the default, and throughput was the same at 16, 256, and 1024 buffers (22,081, 21,816, and 22,319 transactions per second, averaged over four runs each). Every number in this post comes from a two-core VM with synchronous_commit off, so read the ratios and not the absolute figures.
Making it miss
To get a miss at the default, something has to read a page more than about fifteen behind the head. Three things do. A listener that fell behind reads the whole backlog when it catches up (the max_notify_queue_pages post covers how listeners fall behind). A session’s first LISTEN starts from the most advanced listener in its own database, or from the tail of the queue if there is none, so unless a caught-up listener is already in its database it walks the whole backlog too. And since the February 2026 minor releases (18.2, 17.8, and their siblings), a VACUUM that advances the database’s datfrozenxid scans the entire queue before it truncates pg_xact. That scan is part of the fix for the “could not access status of transaction” errors that a slow listener used to be able to produce.
I parked one listener in an open transaction and queued 100,000 pages behind it. That is 781MB, a little under 10% of the default limit. Then I timed the other two readers:
notify_buffers |
first LISTEN |
VACUUM (FREEZE) |
blks_read, each |
|---|---|---|---|
16 (128kB) |
287 ms | 274 ms | 99,999 |
131072 (1GB) |
76 ms | 81 ms | 0 |
On an empty queue the same VACUUM took between 7 and 27 ms. (A VACUUM that doesn’t move datfrozenxid skips the scan and reads nothing.) A miss cost roughly two microseconds more than a hit, and avoiding 100,000 of them saved a fifth of a second, for the price of a cache big enough to hold the entire backlog. With the operating system’s cache dropped first, the LISTEN at 16 buffers took 1.1 seconds. In a smaller test, a listener released after 400,000 notifications had piled up (10,810 pages) received all of them in 0.9 seconds at the default, and in 0.9 to 1.0 seconds with a cache large enough to hold every page.
The 10%
The one workload where the setting showed up in throughput was notifications with payloads near the 8,000-byte limit, so that every commit fills a queue page. Eight clients doing that averaged 13,553 transactions per second at 16, 14,281 at 32, 14,906 at 256, and 14,526 at 1024, four runs each.
Every one of those pages gets written to pg_notify once, at every cache size I tried. What changes is who does the writing. At 16 buffers, about half the writes were evictions, done by a commit that needed room for a new page, while it held the cluster-wide lock that notifying commits take turns on. At 32 buffers that fell to 3%, and at 256 to none; the writes moved to the trimming of the queue’s tail, which runs after the lock is released. I suspect that is where the 10% comes from, but I have not proved it, and the run-to-run noise at 16 was nearly as large as the effect.
If your application really does send a queue page per commit, thousands of times a second, 256 costs 2MB and may buy a few percent. I’d also look hard at what is being put in those payloads.
What is slow instead
When NOTIFY is slow, this cache is almost never why. Notifying commits run one at a time across the whole cluster, and in the runs where I sampled wait events, the most common one among notifying backends was Lock:object. Through PostgreSQL 18, each of those commits also signals every listener in its database, whatever channel that listener is on. Fifty idle listeners on a channel nobody notified took the 22,000-a-second test down to about 3,700, and it stayed there at 16 and at 1024. PostgreSQL 19 wakes only the backends listening on the notified channel (plus any that have fallen well behind); the same test on 19 beta 4 ran at 20,247, against 21,635 with no listeners at all.
Checking the cache has a catch. A backend reports its SLRU counters to pg_stat_slru when it runs a command or disconnects, and reading notifications while idle is neither. (There is an idle timer that flushes leftover statistics, but commands arm it and notifications don’t.) I had an idle listener take 19,999 misses and show none of them ten seconds later; they appeared the moment it ran SELECT 1. The same thing happens on 19 beta 4. So blks_read for notify can sit at zero while a long-lived listener misses on every page.
pg_notification_queue_usage() multiplied by max_notify_queue_pages is the queue’s length in pages, and it has no such lag. While that number stays in single digits, there is no backlog for a larger cache to hold. When it is in the thousands, a notify_buffers large enough to hold all of it makes it a couple of microseconds a page cheaper to read, and the backlog is still there. Leave this at 16 and go find the listener.