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();
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
NOTIFYfor 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
UUIDv7 Explained: Time-Ordered IDs and Why Postgres 18 Added uuidv7()
UUIDv7 is a UUID that starts with a millisecond timestamp, so new IDs sort in creation order. Why that makes database indexes faster than random UUIDv4, the format in detail, generating them in Postgres 18, Node.js and Python, what the timestamp leaks, and when to still use v4 or bigint.
Optimistic vs Pessimistic Locking: Preventing Lost Updates
Two users edit the same record and one silently overwrites the other. Pessimistic locking (SELECT FOR UPDATE) blocks the second writer; optimistic locking (a version column) detects the conflict. How each works, code for both, atomic updates that avoid locks entirely, and how to choose.