Skip to content

Web Development · Backend

Postgres LISTEN/NOTIFY: Real-Time Events Without a Broker

Postgres LISTEN/NOTIFY pushes events to connected clients with no extra infrastructure. Working Node.js code, a trigger, and the limits that bite in production.

Abhishek Gupta

Abhishek Gupta

6 min read

A glass dome with a glowing orb sending light ripples across the floor toward several small lamps on separate pedestals

Sponsored

Share

Postgres LISTEN/NOTIFY pushes real-time events to your application with no Redis, no Kafka and no broker at all. LISTEN subscribes a connection to a named channel, NOTIFY sends a string to everyone subscribed, and the whole thing is built in. For a lot of “tell me when this row changes” problems, it’s the cheapest correct answer.

It also has sharp edges, and most of them show up after launch. Here’s the working version first, then the limits.

The 30-second version

LISTEN subscribes a database connection to a named channel, and NOTIFY sends a short text payload to every connection subscribed to it, delivered once the sending transaction commits. It is built into Postgres, needs no extension, and is meant for low-volume signals rather than durable messaging.

Open two psql sessions. In the first:

LISTEN order_events;

In the second:

NOTIFY order_events, '{"order_id": 1042}';

The first session prints an asynchronous notification with that payload. That’s the entire API. There’s also a function form, pg_notify(channel, payload), which is the one you want inside triggers and application code because it accepts expressions instead of a string literal.

Fire it from a trigger

You rarely want to remember to call NOTIFY from every code path that touches a table. Put it in a trigger so it can’t be skipped:

CREATE OR REPLACE FUNCTION notify_order_change() RETURNS trigger AS $$
BEGIN
  PERFORM pg_notify(
    'order_events',
    json_build_object('op', TG_OP, 'order_id', NEW.id)::text
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER orders_notify
AFTER INSERT OR UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION notify_order_change();

Notice the payload carries an id and an operation, not the row. That’s deliberate, and the reason is the size limit below.

Listen from Node.js

Use the pg package and a dedicated client, not one pulled from your normal pool:

import pg from "pg";

const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();

client.on("notification", async (msg) => {
  const { order_id, op } = JSON.parse(msg.payload);
  console.log(`order ${order_id} was ${op}`);
  // fetch the full row if you need it
});

await client.query("LISTEN order_events");

client.on("error", (err) => {
  console.error("listener lost", err);
  // reconnect, re-run LISTEN, then run your catch-up query
});

Two details matter here. The LISTEN has to be re-issued after every reconnect, because the subscription lives and dies with the session. And that error handler isn’t decoration. Listener connections die (failovers, idle timeouts on a load balancer), and when yours does, you need a plan for what it missed.

What the documentation guarantees

The PostgreSQL NOTIFY documentation is specific, and these are the lines I design around:

  • Transactional delivery. “If a NOTIFY is executed inside a transaction, the notify events are not delivered until and unless the transaction is committed.” A rolled-back insert never emits a phantom event, which is the property people usually have to build by hand with an outbox table.
  • Ordering. Notifications from the same transaction arrive in the order sent, and messages from different transactions arrive in commit order.
  • De-duplication. If the same channel is signaled several times with identical payload strings in one transaction, only one event is delivered. Distinct payloads are always delivered separately. A bulk update that notifies with a constant payload will collapse to one event, and that may or may not be what you want.
  • Payload size. In the default configuration the payload must be shorter than 8000 bytes.
  • Queue limit. The notification queue is 8GB in a standard installation. If it fills, transactions calling NOTIFY fail at commit. A listener that stops consuming, or a long-open transaction on a listening session, can get you there. pg_notification_queue_usage() tells you how full it is.

The limits that bite

It forgets. Delivery is fire-and-forget. If no one is listening when you NOTIFY, the event goes nowhere, and no error tells you so. A listener that was disconnected for ten seconds simply missed ten seconds of events.

The fix is to treat the notification as a nudge and keep the real state in a table. On startup and after every reconnect, run a catch-up query (WHERE id > $last_seen or WHERE processed_at IS NULL), then process notifications as they arrive. That’s the same shape as a job queue backed by a table with a wake-up signal on top.

Poolers break it. LISTEN is a property of a session. Behind PgBouncer in transaction pooling mode, your “connection” is handed to another client between transactions, and your subscription goes with it. Run listeners on a direct connection, or a session-mode pool. If you haven’t set up pooling carefully yet, connection pooling in production covers the modes.

Every listener costs a connection. One listener process per service is fine. One per web request is a way to exhaust max_connections quickly. Fan out inside your app instead: one Postgres listener, then an in-process event emitter or a server-sent events endpoint to the browsers.

It doesn’t scale like a broker. There’s no consumer group, no replay and no backpressure. Ten workers listening on one channel all receive every message, so if you want work split between them you need SELECT ... FOR UPDATE SKIP LOCKED on a table after the wake-up.

When to use it, and when to leave it

LISTEN/NOTIFY is a good fit for cache invalidation, refreshing a live admin dashboard, waking a worker that polls a table, and pushing a “something changed, refetch” signal to a UI. Volume is low, loss is tolerable or recoverable, and you’ve avoided a new moving part.

Reach for a real broker when you need durability, replay, consumer groups or serious throughput. Message queues, pub/sub and event streaming lays out how to pick between those. The honest test is simple: if losing an event for a minute would cost money, don’t make LISTEN/NOTIFY your only copy of it.

The takeaway

Use pg_notify from a trigger, send ids rather than rows, listen on a dedicated connection, and always pair it with a catch-up query. Done that way, LISTEN/NOTIFY gives you real-time behavior from infrastructure you already run.

Frequently asked questions

What is LISTEN/NOTIFY in PostgreSQL?
It is a built-in publish/subscribe mechanism. One session runs LISTEN on a channel name, and any session can run NOTIFY on that channel with an optional text payload. Postgres delivers the message to every listening session, once the sending transaction commits.
How big can a NOTIFY payload be?
In the default configuration the payload must be shorter than 8000 bytes, according to the PostgreSQL documentation. In practice you should send a small identifier or JSON with a row id and let the listener query for the full data.
Does LISTEN/NOTIFY guarantee delivery?
No. If no client is listening when the notification is sent, it is delivered to nothing, and a client that disconnects misses anything sent while it was away. Use it as a wake-up signal and keep the source of truth in a table you can re-read.
Does LISTEN/NOTIFY work with PgBouncer?
Not in transaction pooling mode, because LISTEN is tied to a session and a transaction pooler hands your connection to someone else between transactions. Give listeners a direct connection or a session-mode pool.
When should I use a message broker instead?
Use a broker when you need durable delivery, consumer groups, replay or high throughput. LISTEN/NOTIFY suits low-volume signals such as cache invalidation, live dashboards refreshes and waking up a worker.

Sources

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored