Real-Time Apps with LISTEN/NOTIFY

PostgreSQLBackendReal-TimeDatabase
Share on LinkedIn Share on X Share on Reddit Share on HN Share on Bluesky

We needed dashboard widgets to refresh when batch jobs finished — no Kafka in this environment, just Postgres. NOTIFY job_complete, '{"job_id": 42}' from a trigger plus a Node listener forwarding to WebSockets worked for 200 concurrent users. It would not work for payment events requiring exactly-once delivery. LISTEN/NOTIFY is a signaling channel, not a message broker.

Basic mechanics

-- Session A (listener)
LISTEN order_updates;

-- Session B (notifier)
NOTIFY order_updates, '{"order_id": "abc-123", "status": "shipped"}';

Listener receives asynchronous notification on open connection. Channel names are identifiers — lowercase recommended, max 63 bytes.

// node-postgres listener (dedicated connection — not pooled)
const client = new Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
await client.query('LISTEN order_updates');

client.on('notification', (msg) => {
  const payload = JSON.parse(msg.payload);
  websocketBroadcast('order:' + payload.order_id, payload);
});

Use a dedicated connection for LISTEN — PgBouncer transaction mode and pool return break long-lived listen sessions.

NOTIFY from triggers

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

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

Every write fires NOTIFY — high-write tables can flood listeners. Filter in trigger:

IF NEW.status IS DISTINCT FROM OLD.status THEN
  PERFORM pg_notify(...);
END IF;

Payload discipline

8000 byte limit. Pattern:

PERFORM pg_notify('events', NEW.id::text);
-- Listener: SELECT * FROM events WHERE id = $1

Or send minimal envelope:

{"t": "order.shipped", "id": "abc-123"}

Scaling listeners

One NOTIFY reaches all listeners on channel — fan-out is O(listeners) inside Postgres. Hundreds of app servers each with LISTEN connection adds connection count pressure.

Architecture:

Postgres NOTIFY ──► 1–3 bridge pods (LISTEN) ──► Redis Pub/Sub or WebSocket hub ──► clients

Don't put LISTEN on every API pod — consolidate bridge services.

Reliability boundaries

Requirement NOTIFY OK?
Cache invalidation hint Yes
Live UI refresh (best effort) Yes
Payment processing No
Audit log No
Multi-service CDC No

For at-least-once: transactional outbox table + worker polling or logical replication.

-- Outbox pattern (durable)
INSERT INTO orders (...) VALUES (...);
INSERT INTO outbox (aggregate_id, event_type, payload) VALUES (...);
-- Same transaction — worker publishes to queue

vs logical decoding

Logical decoding reads WAL stream — durable, replayable, consumer groups via tools (Debezium, pg_logical).

NOTIFY: microsecond latency, zero infra, no persistence.

Choose NOTIFY for "wake up and refetch" semantics. Choose logical decoding for event-driven architecture.

Pitfalls

WebSocket bridge implementation sketch

Production setups rarely expose Postgres listeners directly to browsers. A small bridge service holds the dedicated LISTEN connection and forwards to your WebSocket tier:

// bridge.js — one process per availability zone
const { Client } = require('pg');
const WebSocket = require('ws');

const pg = new Client({ connectionString: process.env.DATABASE_URL });
const wss = new WebSocket.Server({ port: 8081 });

await pg.connect();
await pg.query('LISTEN app_events');

pg.on('notification', (msg) => {
  const payload = JSON.parse(msg.payload);
  wss.clients.forEach((ws) => {
    if (ws.subscribedChannels?.has(msg.channel)) {
      ws.send(JSON.stringify({ channel: msg.channel, ...payload }));
    }
  });
});

Clients subscribe to channels after auth — never broadcast sensitive NOTIFY payloads to unauthenticated sockets. Reconnect the Postgres client on error and end events with jittered backoff; during outage, clients should fall back to polling every 30–60 seconds.

Security hardening

Grant LISTEN only to bridge role; application roles should not listen on sensitive channels. Validate NOTIFY payload size before broadcast — oversized payloads fail silently or truncate.

Operational notes

Load test NOTIFY rate before production — high-frequency triggers on wide tables can saturate listener CPU. Batch notifications or debounce per aggregate ID when events burst.

Monitor bridge service health separately from API health — NOTIFY pipeline can fail while HTTP returns 200, leaving users on stale WebSocket data without obvious errors.

Production checklist

Payload size limit

NOTIFY payload max 8000 bytes — send id only, clients fetch full row via API. Pattern: NOTIFY orders_channel with NEW.id then SELECT by id.

Connection lifecycle with poolers

LISTEN requires dedicated connection — do not share with PgBouncer transaction pool. Sidecar listener process or LISTEN on direct Postgres connection.

Missed notifications

Listeners disconnect during network blip — reconcile with periodic poll or outbox table. NOTIFY is best-effort; critical workflows need outbox plus consumer.

Channel namespacing

tenant_{id}_events prevents cross-tenant leakage if client subscribes wrong channel. Validate tenant in payload even when channel scoped.

Debouncing rapid NOTIFY bursts

Bulk import firing NOTIFY per row overwhelms listeners — batch commits and send one NOTIFY with batch id; clients fetch WHERE updated_at > $cursor. Pattern mirrors CDC debouncing.

LISTEN connection health check

Application heartbeat: if no NOTIFY in 5 minutes on active system, reconnect LISTEN socket — silent disconnect otherwise discovered only when stale UI reported.

Scaling listeners horizontally

Each listener connection consumes one Postgres backend — 50 websocket servers means 50 LISTEN connections. Consolidate through Redis pub/sub bridge: one LISTEN process NOTIFY→Redis PUBLISH; app servers subscribe Redis — decouples fan-out from Postgres connection count.

Security: who may LISTEN

Grant LISTEN on specific channel via RLS on pg_notify wrapper function SECURITY DEFINER — raw NOTIFY from untrusted role could spam channels. Application-only NOTIFY through stored procedure validates tenant before pg_notify call.

NOTIFY payload encoding

JSON payload string must fit 8000 bytes — compress ids array or send version number only. Binary data forbidden — base64 inflates size; send blob id reference instead.

Fallback polling interval

When NOTIFY missed, client polls every 30s as backstop — websocket push primary. Exponential backoff on poll when no changes reduces load; reset interval on received NOTIFY. Document hybrid push-poll in frontend architecture note for realtime dashboard team.

Closing notes

Load test NOTIFY rate before peak event — listener single-threaded processing may bottleneck below Postgres NOTIFY capacity; queue depth metric alerts when processing lag exceeds one second.

Additional guidance

Combine NOTIFY with transactional outbox for critical notifications — NOTIFY after COMMIT in same transaction as outbox insert using trigger; listener processes outbox row id from NOTIFY payload ensuring at-least-once delivery if listener crashes mid-processing without losing event entirely.

PgBouncer does not forward NOTIFY to clients connected through transaction pool — architectural constraint driving Redis bridge pattern or direct Postgres connection for listener workers documented in platform realtime architecture decision record approved by infra team.

Use transactional outbox plus NOTIFY for must-deliver events — NOTIFY alone drops messages if listener disconnected during network partition.

Bridge NOTIFY through Redis when more than ten app servers subscribe — Postgres listener connection count scales poorly compared to pub/sub fan-out pattern.

Listener process should ACK processing by deleting outbox row only after downstream websocket fan-out succeeds — at-least-once delivery without duplicate user notifications requires idempotent client message ids.

Resources

Frequently asked questions

What are the payload size limits for NOTIFY?

The payload string is limited to 8000 bytes in standard PostgreSQL builds. Send an ID or event type in NOTIFY and fetch full data separately. Never put large JSON documents in the payload.

Is LISTEN/NOTIFY reliable message delivery?

No — it's fire-and-forget. If no listener is connected when NOTIFY fires, the message is lost. No replay, no persistence. Use logical replication, message queues, or outbox pattern when delivery guarantees matter.

When should I use logical decoding instead of NOTIFY?

When you need durable event streams, multiple consumers, replay from a LSN, or CDC to external systems. NOTIFY suits low-latency same-database signaling — cache invalidation hints, WebSocket fan-out triggers — not event sourcing.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →