Postgres LISTEN/NOTIFY Can Actually Scale

title
Postgres LISTEN/NOTIFY Can Actually Scale
type
summary
summary
How DBOS took Postgres LISTEN/NOTIFY from 2.9K to 60K writes/sec by batching notifications
tags
postgresql, databases, performance, pub-sub, concurrency
created
2026-07-29
updated
2026-07-29

DBOS wrote this in July 2026 as a direct answer to a widely-circulated recall.ai post claiming Postgres LISTEN/NOTIFY does not scale. Their concession is that the measurements in that post are correct and the behaviour is genuinely undocumented. Their claim is that the ceiling comes from one specific implementation detail, and that you can work around it without giving up notifications: their LISTEN/NOTIFY-backed streams sustain 60K writes per second on a single Postgres server at 15-100 ms latency.

The design and where it stalls

The product is a stream backed by a table. Each chunk of a stream β€” one token of an LLM response, say β€” is a row, and writing to the stream is an INSERT. Reading is the hard half, because a reader has no way to know when the next chunk lands. Polling gets you a bad trade in both directions: a long interval is too laggy for an interactive chat, and a short one puts every reader on the database at once. LISTEN/NOTIFY replaces that with readers blocking until a writer tells them there is something new.

The obvious implementation is a trigger on the streams table that calls a function to send a notification on every insert. It is correct and it is fast per-message. It also could not push past 2.9K stream writes per second on a large Postgres instance, and β€” the detail that makes the diagnosis interesting β€” it hit that wall without consuming any visible Postgres resource. CPU, memory and IOPS were all unremarkable at the ceiling.

Why the global lock is there

Committing a transaction that called NOTIFY requires a global exclusive lock. The lock is taken as the commit begins and is not released until the transaction is fully committed and flushed to disk with fsync().

Postgres holds that lock because it promises notifications are delivered in transaction commit order. Enforcing that means keeping all outgoing notifications in a global internal queue whose order matches commit order exactly, and enqueueing has to happen transactionally as part of the commit. The circularity is that Postgres does not assign a transaction its commit order until the commit is finished, since commits take variable time. So a transaction needs to know its place in the ordering before the ordering exists. Serializing those commits under one lock resolves it: if only one notifying transaction commits at a time, its position is decided in advance.

That explains the shape of the earlier benchmark. Every stream write carried a NOTIFY, so every stream write had to hold the global lock for the full duration of its own commit including the disk flush. Stream writes committed strictly one after another, which also disqualifies them from group commit, the optimization where Postgres batches many transactions into a single fsync(). Throughput then equals the rate at which Postgres can commit transactions one at a time, and no resource looks saturated because nothing is doing work β€” the backends are waiting.

A Postgres patch slated for Postgres 19 has been discussed as a fix for this. DBOS say it is not: it leaves the global lock in place and optimizes a narrower case, where there are many notification channels and each listener waits on one specific channel.

The workaround

The load-bearing observation is about what the notification is for. In this design, and in most uses of LISTEN/NOTIFY, the notification carries no truth of its own. The table is the source of truth, and the notification only tells a reader to go look at it. A ping that need not be globally ordered and need not be perfectly durable can be buffered in memory and flushed periodically as one batch transaction.

That moves the global lock off the hot path. It is taken once per buffer flush rather than once per stream write, so individual writes commit normally, group commit applies again, and the flush happens in the background. The gap between 2.9K and 60K writes per second is almost entirely this change. At the new ceiling Postgres CPU is fully utilized, which is the evidence that the database is actually saturated rather than parked on lock contention.

The buffer introduces the failure it sounds like it introduces: a process that crashes with notifications still in memory never delivers them. The fallback is to give readers a slow poll in addition to their blocking wait, so a stream written without a notification is still picked up. Because the poll only covers dropped notifications rather than normal delivery, its interval can be long enough not to matter for load.

The benchmark code is at dbos-inc/dbos-postgres-benchmark.

Notes

The failure mode here is a useful one to recognize in postgresql generally: a hard throughput ceiling with no resource pegged means contention, not capacity. The inverse case appears in linux-7-postgres-regression, where a kernel scheduler change lets a process be preempted mid-page-fault while holding a Postgres spinlock and the symptom is the opposite β€” CPU burning hot while throughput collapses. Both are lock behaviour; only one of them shows up in a CPU graph.

The broader point is about what Postgres can absorb before you add a second system. Durable pub/sub and low-latency streams are the usual reason to run a dedicated broker alongside the database; the numbers here argue that one Postgres server covers a large fraction of that. The same "one datastore is enough if you understand its knobs" argument shows up for the small end in sqlite-in-production, and the cost of not knowing an engine's internals shows up in valkey-secret-life-of-data, where an undocumented encoding threshold changes memory use by 77%.