The Practical Developer

Postgres LISTEN/NOTIFY: The Pub/Sub You Already Have And Are Not Using

Most teams reach for Redis pub/sub or a message broker before their actual traffic warrants it. Postgres has had pub/sub built in since 2010. Here is the working pattern, the limits to watch, and how to migrate to a real broker once you outgrow it.

A close-up of a circuit board — the right metaphor for the hidden machinery already in your database

The team needs a pub/sub channel. One service inserts a row; another should react in real time. The instinct is to add Redis pub/sub or set up a Kafka topic. For a 20-engineer team writing 50 events per second, both are massively over-engineered. Postgres has had LISTEN / NOTIFY since 2010 — it’s free, it’s already running, and it works for everything below the “we genuinely need a real broker” threshold.

This post is the working pattern: how LISTEN / NOTIFY actually works, the right way to use it from Node.js, the four traps, and the load level at which you should consider migrating off.

The mechanism

Postgres has two SQL commands:

  • NOTIFY channel_name, 'optional payload' — fires a notification on the named channel.
  • LISTEN channel_name — registers the current session as a listener on that channel.

A connection that is LISTENing receives a notification asynchronously when anyone calls NOTIFY on the same channel. The notification carries the channel name, the optional payload (≤8000 bytes), and the PID that fired it.

-- Session 1
LISTEN order_paid;

-- Session 2
NOTIFY order_paid, '{"orderId": "abc-123"}';

-- Session 1 receives:
-- Asynchronous notification "order_paid" with payload "{...}" received from server process with PID 1234.

That’s the whole API. No setup, no schema. The notification is delivered exactly once to each listening session.

The Node.js pattern

Most Postgres clients can listen for notifications. With node-postgres:

import { Pool, Client } from 'pg';

// LISTEN needs a dedicated connection — not a pooled one.
const listenClient = new Client({ connectionString: process.env.DATABASE_URL });
await listenClient.connect();

await listenClient.query('LISTEN order_paid');

listenClient.on('notification', async (msg) => {
  // msg.channel = 'order_paid', msg.payload = '{...}'
  const data = JSON.parse(msg.payload!);
  await handleOrderPaid(data);
});

listenClient.on('error', (err) => {
  console.error('listen connection error', err);
  // Reconnect logic — see below.
});

// Producer side: use a normal pool.
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
await pool.query(`NOTIFY order_paid, $1`, [JSON.stringify({ orderId: 'abc' })]);

Two non-obvious things:

LISTEN must be on a dedicated connection. Pooled connections cycle between requests; the LISTEN registration is lost. Use a single dedicated Client for listening, not a pool.

Reconnection is your responsibility. If the connection drops (DB restart, network hiccup), notifications during the gap are lost. The reconnect logic must LISTEN again on the new connection. There is no “deliver pending notifications” mechanism.

A robust listener

async function startListener(channels: string[], handler: (msg: any) => Promise<void>) {
  let stopped = false;

  async function loop() {
    while (!stopped) {
      const client = new Client({ connectionString: process.env.DATABASE_URL });
      try {
        await client.connect();
        for (const ch of channels) await client.query(`LISTEN ${ch}`);

        client.on('notification', (msg) => {
          handler({ channel: msg.channel, payload: JSON.parse(msg.payload ?? '{}') })
            .catch(err => console.error('handler error', err));
        });

        await new Promise<void>((resolve) => {
          client.on('error', resolve);
          client.on('end',   resolve);
        });
      } catch (err) {
        console.error('listen loop error', err);
      } finally {
        await client.end().catch(() => {});
      }
      // Backoff before reconnecting.
      await new Promise(r => setTimeout(r, 1000));
    }
  }

  loop().catch(console.error);
  return () => { stopped = true; };
}

// Usage
startListener(['order_paid', 'order_cancelled'], async ({ channel, payload }) => {
  if (channel === 'order_paid') await handleOrderPaid(payload);
  if (channel === 'order_cancelled') await handleOrderCancelled(payload);
});

Reconnects automatically after errors. Backoff prevents thundering herd if the DB is briefly down.

The transactional NOTIFY pattern

NOTIFY is transactional — the notification is delivered when the transaction commits. If the transaction rolls back, no notification fires. This is important: you can use it to make sure listeners don’t see events before the data is durable.

BEGIN;
INSERT INTO orders (id, amount) VALUES ('abc', 100);
NOTIFY order_paid, '{"orderId": "abc"}';
COMMIT;
-- Listener receives notification only after COMMIT.

This is how you avoid the dual-write problem at small scale: do both in one transaction. For larger scale, use the outbox pattern with a NOTIFY-driven dispatcher (see below).

The “outbox + NOTIFY” pattern for low-latency

For a real outbox-pattern implementation that uses NOTIFY to wake up the dispatcher instead of polling:

-- Producer
BEGIN;
INSERT INTO outbox (topic, payload) VALUES ('order_paid', '{"orderId": "abc"}');
NOTIFY outbox_new;
COMMIT;
// Dispatcher
await listenClient.query('LISTEN outbox_new');
listenClient.on('notification', async () => {
  await processOutbox(); // poll outbox table for unprocessed rows
});

The dispatcher doesn’t poll on a timer — it polls only when notified. Sub-millisecond latency at zero idle CPU. This is the under-used combination that makes Postgres-as-a-message-bus genuinely viable.

The four traps

1. Lost messages on disconnect. Not stored. Listener offline → notifications gone. If you need durability, use the outbox table; LISTEN/NOTIFY is only the wakeup signal.

2. Payload size limit. 8000 bytes maximum. Don’t send big payloads — send a key, look up the data.

3. Single Postgres bottleneck. All notifications go through the primary’s lock manager. At very high rates (thousands per second), this becomes a bottleneck.

4. Cannot replay. Once delivered, gone. No “send me everything from the last hour.”

When LISTEN/NOTIFY is the right tool

Stay with LISTEN/NOTIFY when:

  • Notification rate is < 1000/second.
  • You don’t need durability beyond “the event was triggered by a committed transaction” (because data is in a table; the notification is the wakeup).
  • Your subscribers are inside the same Postgres database’s network reach.
  • You’re already running Postgres for application data and don’t want a second moving piece.

For low-volume event-driven flows, LISTEN/NOTIFY combined with an outbox table is exactly enough.

When to migrate to a real broker

Three signs:

  1. Notification volume > 1000/sec. Postgres’ notification queue becomes a bottleneck.
  2. You need replay. “Re-process the last hour of events” doesn’t work with LISTEN/NOTIFY.
  3. You need fanout to many independent consumers with different consumption rates.

For these, move to Kafka or RabbitMQ (see the comparison). The migration: add a Kafka topic, dual-publish for a transition period, switch consumers, drop LISTEN/NOTIFY.

Practical use cases

A few real examples where LISTEN/NOTIFY is great:

  • Refresh dashboards in real time. A web app’s WebSocket server LISTENs on dashboard_update; when underlying data changes, the change is broadcast to all connected clients.
  • Cache invalidation. Multi-instance app cache layer LISTENs on cache_invalidate; when an admin updates a config, all instances drop their cached copy.
  • Job queue wakeup (described above).
  • Real-time chat for low-volume B2B apps — message inserted, NOTIFY, recipient’s WebSocket session broadcasts.

Each of these would be over-engineered if implemented with Kafka. LISTEN/NOTIFY is the right size for them.

Performance numbers

Some real numbers from a moderately-sized Postgres (8 cores, 32 GB RAM):

  • ~10,000 notifications/sec sustained without significant impact on the primary.
  • Latency from NOTIFY to listener receiving: <5ms typical, <50ms p99.
  • 1,000 concurrent listeners: fine. 10,000: starts to load the connection pool.

These are conservative; real-world performance is often higher. The number that bites is connection count — each listener needs a dedicated connection, so 1000 listeners means 1000 connections. That’s where pgbouncer and connection management matter.

The takeaway

Postgres has pub/sub built in. For low-volume event-driven flows on top of an existing Postgres, LISTEN/NOTIFY is the right tool. Combined with an outbox table for durability, it gives you a complete event system without adding a broker. Use a dedicated connection for the listener, handle reconnects, send keys not blobs.

The next time someone says “we need a pub/sub system,” ask whether the existing Postgres is enough. For most small-to-mid scale teams, it is — until it isn’t, at which point the migration is well-understood.


A note from Yojji

The kind of architecture judgment that picks the right-sized tool — LISTEN/NOTIFY for low-volume event flows, a real broker only when volume demands it — is the kind of senior engineering Yojji’s teams bring to client work.

Yojji is an international custom software development company founded in 2016, with teams across Europe, the US, and the UK. They specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, GCP), and Postgres-centric backends — including the integration patterns that decide whether your event flow stays simple or accumulates infrastructure.