PostgreSQL LISTEN/NOTIFY: How Far It Goes, and When to Move to a Message Queue

4 min read

LISTEN/NOTIFY is excellent as a wake-up signal and unsuitable as a message queue. That one line is the conclusion; this article explains why. PostgreSQL ships with pub/sub that needs no extra infrastructure, pushing events in real time without polling. The trouble is the incidents that follow from misusing it as a queue — know the boundary line precisely and it’s a genuinely useful tool.

Behavior: notifications are bound to the transaction #

LISTEN/NOTIFY example
-- listener (session A)
LISTEN order_events;

-- sender (session B)
BEGIN;
INSERT INTO orders (user_id, amount_usd) VALUES (42, 89);
SELECT pg_notify('order_events', '1204');   -- send only the new order's id
COMMIT;  -- the notification is delivered at this moment

The key property: notifications go out at transaction commit, and only if the commit happens. On ROLLBACK, the notification never existed. The consistency problem you fight when pairing an external message broker with database writes — “the database rolled back but the message went out” — structurally cannot occur. That’s LISTEN/NOTIFY’s biggest attraction, and fan-out comes free: every session listening on a channel receives the delivery.

Three limits: size, volatility, scale #

  • The 8,000-byte cap. Payloads top out at 8,000 bytes. This was never a channel for carrying data, so the standard is “the body lives in a table; the notification carries only a key.” That’s why the example sent only an id rather than the whole order as JSON. The receiver SELECTs by key to read the current state (by the time the notification arrives, the state may have changed again — re-reading is correct anyway).
  • Volatility. Notifications are not stored. With no session LISTENing they simply vanish, and notifications during a listener’s reconnect window are lost too. In other words, they cannot be the delivery mechanism for work that must be processed. Durable delivery requires the data itself to live in a table.
  • The scale bottleneck. To guarantee commit-order delivery, notifications pass through a single instance-wide queue. At high publish volume, the commit path itself gets caught in this serialization, slowing commits database-wide — production outages at large services have been reported from exactly this. (Improvements are in progress upstream, but on current stable releases the limit is real.) Translation: don’t use it as a high-frequency publish channel.

The standard combination: notifications wake, the table queues #

The three limits converge on one design. The work queue’s body is the FOR UPDATE SKIP LOCKED table queue; LISTEN/NOTIFY serves only as the “new work available” wake-up signal for workers.

Worker loop design
enqueue: INSERT INTO jobs ... ; pg_notify('jobs', '');   -- no payload even needed
worker loop: wait for notification → wake and process whatever SKIP LOCKED can claim → wait again
             (+ polling every few tens of seconds as the safety net against lost notifications)

In this combination, a lost notification is picked up by the safety-net poll, and work enqueued while no worker was alive sits safely in the table. The notification is merely “an accelerator that shrinks the polling interval to milliseconds” — correctness is entirely the table queue’s responsibility. LISTEN/NOTIFY alone is the right fit for notifications where missing one is caught up later: cache-invalidation broadcasts, live admin-dashboard refreshes, config-reload signals.

One caveat: LISTEN leaves state on the session, so it does not pass through PgBouncer’s transaction mode. The standard is connecting listener connections directly to the database, bypassing the pooler, as dedicated connections (worker counts are usually small, so this costs little).

The criteria for a real message queue #

The above in-database combination defends a wide range, but these signals mark the seat of a dedicated messaging system (SQS, RabbitMQ, Kafka, and so on): throughput high enough that the queue table’s writes and VACUUM pressure the database’s day job; event streams with multiple consuming services (replay and retention requirements); publishers and consumers living in different databases, teams, or systems. Conversely, for background work within a single service, the table queue + notification combination comes before adding a piece of infrastructure. The managed-queue value covered in the SQS comparison is the story after that line is crossed.

Summary #

  • LISTEN/NOTIFY’s attraction is transactional consistency: it ships only on commit, and rollback unsends it.
  • The limits are 8,000 bytes, volatility, and the commit-serialization bottleneck at high volume. Unfit for data transport, guaranteed delivery, or high-frequency channels.
  • The standard is a division of labor: the queue’s body is a SKIP LOCKED table; notifications are the wake-up signal plus a safety-net poll.
  • Send only keys and let receivers re-read. Listener connections bypass PgBouncer transaction mode and attach directly.
  • When throughput, multi-consumer streams, or system boundaries grow, move to a dedicated message queue. Until then, the in-database combination is the minimum-operations answer.
X