






















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.
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.
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.
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.
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).
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.
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.”
Stay with LISTEN/NOTIFY when:
For low-volume event-driven flows, LISTEN/NOTIFY combined with an outbox table is exactly enough.
Three signs:
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.
A few real examples where LISTEN/NOTIFY is great:
LISTENs on dashboard_update; when underlying data changes, the change is broadcast to all connected clients.LISTENs on cache_invalidate; when an admin updates a config, all instances drop their cached copy.Each of these would be over-engineered if implemented with Kafka. LISTEN/NOTIFY is the right size for them.
Some real numbers from a moderately-sized Postgres (8 cores, 32 GB RAM):
NOTIFY to listener receiving: <5ms typical, <50ms p99.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.
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.
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.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。