Blog
3 min read

Postgres LISTEN/NOTIFY: Real-Time Events Without Another Service

LISTEN and NOTIFY let Postgres send messages to connected clients when something happens — new orders, cache invalidation, live dashboards — without Redis or a message broker. How it works, a Node.js example with triggers, the 8000-byte payload limit, why notifications aren't durable, and the PgBouncer gotcha.

Postgres has a built-in publish/subscribe system. A client runs LISTEN some_channel; anyone runs NOTIFY some_channel, 'message'; every listening connection receives the message almost instantly. No Redis, no message broker, no polling. (Message Queues Explained)

The basics

In one psql session:

LISTEN orders;

In another:

NOTIFY orders, '{"id": 42, "status": "paid"}';
-- or, from a function or with a dynamic channel name:
SELECT pg_notify('orders', '{"id": 42, "status": "paid"}');

The first session receives Asynchronous notification "orders" with payload "{...}".

Notify from a trigger

The real power is notifying automatically when data changes:

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

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

(Postgres Triggers)

Listening from Node.js

import pg from 'pg'

const client = new pg.Client({ connectionString: process.env.DATABASE_URL })
await client.connect()
await client.query('LISTEN orders')

client.on('notification', msg => {
  const event = JSON.parse(msg.payload)
  console.log('order changed', event)
  // push to browsers via SSE/WebSocket, invalidate a cache, wake a worker…
})

client.on('error', err => {
  console.error('listener died', err)
  process.exit(1) // let your process manager restart and re-LISTEN
})

Use a dedicated connection for listening — not one from a pool that gets handed to other queries. (Server-Sent Events vs WebSockets)

The rules you must know

  • Notifications are sent on commit. If the transaction rolls back, nothing is sent. Within a transaction, identical notifications on the same channel are merged.
  • They're not durable. If no one is listening — your listener crashed, was restarting, or lost its connection — the message is simply gone. There's no replay.
  • Payload limit: under 8000 bytes by default. Send an ID and let the listener fetch the row, rather than the whole record.
  • The queue is finite (8 GB by default). A listener that stays connected but never reads can eventually block NOTIFY for everyone. Make sure listeners keep consuming.
  • Every NOTIFY takes a brief global lock at commit, so extremely high notification rates can become a bottleneck on busy databases.

The PgBouncer gotcha

In transaction pooling mode, PgBouncer gives your client a different server connection for each transaction, so LISTEN is registered on a connection you won't keep. Listeners must connect directly to Postgres (or through a session-mode pool). (Postgres Connection Pooling)

Good uses

  • Live dashboards and admin screens (push changes to browsers).
  • Invalidating an in-memory cache across several app servers.
  • Waking a background worker immediately instead of polling every few seconds — pair it with a durable job table so nothing is lost. (Postgres SKIP LOCKED Job Queue)

When to use something else

  • You need guaranteed delivery or replay → a job table, the outbox pattern, or a real broker. (Transactional Outbox Pattern)
  • You need every change reliably streamed elsewhere → logical replication / change data capture. (Change Data Capture in Postgres)
  • Very high message volume → a dedicated system.

The common robust pattern: write the event to a table, then NOTIFY. Listeners react instantly when they're up, and catch up from the table after a restart.

The summary

  • LISTEN channel + NOTIFY channel, 'payload' = built-in pub/sub.
  • Delivered on commit, under 8000 bytes, and not durable.
  • Use a dedicated direct connection (not PgBouncer transaction mode), and back it with a table if messages matter.

EasySpawn runs Postgres on the same server as your app, so a listener connection is local, fast and stable — and Claude Code can wire up the trigger and the listener. See how it works or join the waitlist.

Related: WebSockets Explained · Webhooks vs Polling · Database Transactions Explained · BullMQ Tutorial

Keep reading