12 min read

PostgreSQL LISTEN/NOTIFY Pitfalls: It Is a Signal Bus, Not a Queue

We lost a night of order events because someone treated NOTIFY as a durable queue. It is a signal bus with an 8000-byte payload limit and no replay — here is how to use it without learning that the hard way.

We lost a night of order events once. Not to a crash, not to replication lag — to a design review answer. Someone had wired cache-busting and job dispatch through NOTIFY payloads, a deploy bounced the consumers, and every event emitted during the bounce was simply gone. No errors anywhere, because nothing failed: NOTIFY delivered exactly what it promises, which is less than most people assume. That incident review ends with a sentence I have repeated in every design review since: LISTEN/NOTIFY is a signal bus. The moment you treat it as a queue, you have built a lossy system and just have not noticed yet.

Delivery semantics: commit-time, at-most-once, no replay

A NOTIFY fires only if and when its transaction commits — roll back and the notification never existed, which is genuinely useful and the one queue-like property it has. Beyond that, the semantics are bus semantics. Notifications go to sessions that are connected and listening at delivery time — and connected is forgiving here: a listening session that is idle between commands still gets its events, because the backend hands pending notifications to the client as soon as it finishes whatever it is doing, and even a client that only polls while running statements sees them at its next interaction. What loses events is being disconnected: a session that is down or restarting gets nothing, and there is no log to replay, because the notification is not stored for later delivery to anyone. At-most-once, live-only. Within a single transaction, duplicate notifications with the same channel and payload are collapsed and delivered once, which you can use deliberately for coalescing.

The resync pattern that follows from this has a strict order, and getting it backwards is its own classic bug: LISTEN first, then read current state from the tables, then start processing. If you snapshot first and then LISTEN, every event committed between your snapshot and your LISTEN is lost forever. Listen-then-snapshot has the opposite, harmless property: you may process an event twice, which is why consumers must be idempotent — but you never miss one. Every correct LISTEN/NOTIFY consumer I have ever reviewed has this shape, and every incident review I have sat through had it the other way around.

The 8000-byte ceiling, and why payloads should be hints

The payload is a text string with a hard ceiling just under 8000 bytes — go over and the NOTIFY errors at execution time with "payload string too long". The queue's record format has to stay bounded, and the documentation is blunt about the number. In practice the ceiling is a design constraint, not an inconvenience. Treat the payload as a hint: an ID, a version counter, a shard number, maybe a table name. The consumer reads the actual data from the source-of-truth table when it wakes up. The day you find yourself JSON-encoding whole rows into payloads, you have reinvented a queue with worse guarantees than the table you are copying from.

One global queue for the whole cluster

There is exactly one notification queue per database cluster, shared by every database, channel, and listener. PostgreSQL exposes its fill level through pg_notification_queue_usage(), which returns a fraction between 0 and 1. Two operational facts fall out of the shared design. First, a listener that stops consuming — an idle-in-transaction session, a wedged consumer holding an old snapshot — prevents the queue from being truncated past the point that session has seen, so one stuck listener grows the queue for everyone. Second, when the queue fills, it is the senders that pay: a transaction that has called NOTIFY fails at commit with an error. Your cache-invalidation chatter can start erroring out unrelated order writes. The queue is large — gigabytes on a default build — which is precisely the trap: it takes a long time to fill, so the stuck-listener problem accumulates silently for days before anyone gets paged. Watch pg_notification_queue_usage() the way you watch replication lag: it is the shared resource nobody owns until it is on fire.

-- How full is the single, cluster-wide notification queue?
SELECT pg_notification_queue_usage();

-- Which channels is this session listening on?
SELECT * FROM pg_listening_channels();

NOTIFY serializes commits under load

Appending to that one queue is a synchronized operation: at commit, a notifying transaction takes a short exclusive lock on the queue structure to write its records. At a few NOTIFY-commits per second you will never know it exists. At thousands per second you have put a global lock inside your commit path, and the symptom is commit throughput that flatlines while every other resource looks idle. I measured this on a chatty schema where the application notified per row inserted; batching to one NOTIFY per transaction — remember, duplicates within a transaction collapse anyway — cut the commit-time contention visibly and roughly doubled insert throughput on the same hardware. If your event rate is inherently high, the honest answer is that you have outgrown the bus: put rows in a table and poll, or use a real queue, and keep NOTIFY for the wake-up poke.

PgBouncer transaction pooling eats LISTEN

LISTEN registers interest on one specific server session. Under PgBouncer's transaction pooling, your client does not own a server session — it borrows a different backend per transaction, so the LISTEN you issued is either sitting on a pooled backend you will never see again or was never effective at all from the server's point of view. The failure is silent: no error, just no notifications arriving, which makes it a spectacular time sink to debug. Listeners need a dedicated direct connection or session pooling. The full inventory of what transaction pooling breaks — prepared statements, advisory locks, session SET — is in the notes on PgBouncer transaction mode gotchas; LISTEN/NOTIFY is the entry people discover last and debug longest.

Where it shines, and the SKIP LOCKED pairing

Used inside its guarantees, NOTIFY is excellent. Cache invalidation across app instances is the canonical fit: low rate, tiny payloads, loss tolerable because every reconnect triggers a full reload anyway. The other fit is waking workers — and there the correct architecture is a table that is the queue, with NOTIFY as the doorbell:

-- Producer: the row is the event; NOTIFY is only a wake-up poke.
-- One transaction, so the row and the poke commit (or roll back) together:
BEGIN;
INSERT INTO jobs (kind, payload) VALUES ('report', '{"account": 42}');
NOTIFY jobs, 'report';
COMMIT;

-- Consumer: claim work atomically; NOTIFY just saved you a poll cycle.
UPDATE jobs
SET claimed_by = 'worker-7', claimed_at = now()
WHERE id = (
  SELECT id FROM jobs
  WHERE claimed_by IS NULL
  ORDER BY id
  FOR UPDATE SKIP LOCKED
  LIMIT 1
)
RETURNING id, payload;

Workers LISTEN on the channel, and on wake — plus on a slow fallback timer, because wake-ups can be lost — they claim rows with FOR UPDATE SKIP LOCKED, which lets many workers drain one table without blocking each other. Every guarantee lives in the table: durability, retry, ordering where you want it. NOTIFY contributes exactly one thing: latency. It turns a 30-second poll interval into a sub-second reaction without making the bus a single point of correctness. That division of responsibility is the whole pattern, and it survives consumer deploys, bounces, and bad Fridays.

Watching the bus with MonPG

The failure modes here are quiet: queue fill creeping up, idle-in-transaction listeners pinning it, commit latency from queue lock contention. MonPG monitors PostgreSQL today and tracks exactly those server-side vital signs — session states, commit latency, long-lived transactions — so a wedged listener shows up as evidence, not folklore. The PostgreSQL monitoring surface covers the metrics, and if you are weighing queue architectures, the SKIP LOCKED job queue notes go deeper on the table side of the pattern. Keep the bus for signals, keep truth in tables, and NOTIFY will be the most boring component you run — which is the compliment.