Vibin R.

ALL POSTS

Postgres already has a pub/sub. Should you use it?

LISTEN and NOTIFY give you push notifications without adding a broker — until the day they quietly drop your events.

12 Sept 2026 · 8 min readPostgresArchitectureBackend

Most projects reach for a message broker the moment one service needs to tell another that something happened. A new row lands, an order is paid, a job finishes — and suddenly there's a queue to run, a client library to learn and one more thing that can be down at 2am. If the data already lives in Postgres, it's worth knowing that Postgres can do the telling itself.

LISTEN and NOTIFY have been in Postgres for a long time and almost nobody uses them. They are a genuine publish/subscribe channel built into the database you already run. They are also, in a very specific way, a trap. This post is both halves of that story.

The whole API is three statements

A connection subscribes to a named channel, another connection publishes to it, and every listener gets the payload. No topics to declare, no partitions, no consumer groups.

sql

-- connection A: subscribe
LISTEN order_events;

-- connection B: publish
NOTIFY order_events, '{"id": 4821, "status": "paid"}';

-- connection A stops caring
UNLISTEN order_events;

Channel names are ordinary identifiers, so they follow the same rules as table names. The payload is optional, and if you send one it has to be text. In practice that means JSON, which is why the id-plus-status shape above is so common.

Publishing from a trigger

The version that makes this genuinely useful is a trigger. The application keeps doing plain inserts and updates, and the database announces them. Nothing in your write path has to remember to publish.

sql

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

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

pg_notify() is the function form of NOTIFY, and you want it here because the channel and payload can be computed. The statement form takes literals only.

Listening from Node

On the client side the important detail is that a listener needs its own dedicated connection. A pooled connection is handed back after each query, and your LISTEN goes with it, which produces the most confusing bug in this whole feature: notifications that work locally and silently stop in production, where the pool is bigger.

ts

import { Client } from "pg";

const listener = new Client({ connectionString: process.env.DATABASE_URL });

await listener.connect();
await listener.query("LISTEN order_events");

listener.on("notification", (msg) => {
  const event = JSON.parse(msg.payload ?? "{}");
  console.log("order changed", event.id, event.status);
});

// Reconnect, and re-LISTEN, whenever the socket drops.
listener.on("error", async (err) => {
  console.error("listener died", err);
  await reconnectWithBackoff();
});

That last handler is not optional. A dropped connection means a dropped subscription, and Postgres will not replay what you missed while you were away.

The three rules nobody tells you

  • Delivery is transactional. A NOTIFY inside a transaction is only sent when that transaction commits, which is exactly what you want: listeners never hear about a row that got rolled back.
  • Payloads are capped at 8000 bytes. Send an id, not a record. The listener can read the row itself, and it will get the current version rather than a stale copy.
  • There is no persistence and no acknowledgement. If no one is listening at the moment a notification fires, it is gone. Not queued, not retried — gone.
LISTEN/NOTIFY is a doorbell, not a mailbox. It tells whoever is home that something happened. It does not hold the parcel for you.

So when is it the right call?

It fits beautifully when a missed message is an inconvenience rather than a bug: invalidating a cache, refreshing a dashboard someone has open, waking a worker that would otherwise poll every five seconds, nudging a websocket layer to push an update. In all of those, the next event or the next poll heals the gap.

It fits badly when the message is the only record that work needs doing. Payment captured, email to send, invoice to generate — lose one of those and nothing heals it. For that you want the event stored in a table you control.

The pattern that gets you both

The transactional outbox is the usual answer, and it pairs nicely with NOTIFY rather than competing with it. You write the event to a table in the same transaction as the business change, so it can never be lost. Then you notify, so workers pick it up immediately instead of waiting for their next poll.

sql

BEGIN;

UPDATE orders SET status = 'paid' WHERE id = 4821;

INSERT INTO outbox (topic, payload)
VALUES ('order.paid', '{"id": 4821}');

COMMIT;  -- the row is durable, and the NOTIFY fires here

The worker's loop becomes: claim unprocessed rows with SELECT ... FOR UPDATE SKIP LOCKED, handle them, mark them done. The notification only decides how quickly it wakes up. If the listener misses an event, the next poll still finds the row — and you've turned a silent data-loss bug into a latency blip.

What I'd actually do

Start with the outbox table, because it's the part that's painful to retrofit once you have real traffic. Add LISTEN/NOTIFY on top when the polling interval starts to feel slow. And reach for a real broker at the point you need fan-out across many consumers, replay, or backpressure — not before. A queue you don't run is a queue that can't page you.