Cross-posted from my blog. The original, with any updates, lives on fabwaseem.com.

If you already run Postgres, you can push realtime events to your app without adding Redis: NOTIFY sends a message on commit, and every connection that ran LISTEN gets it. I measured Postgres 18 LISTEN/NOTIFY against Redis 8 Pub/Sub for delivery latency, fan-out, and the cost to the transactions that send the events. I also checked what each one does when a message is too big, when a transaction rolls back, and when a listener disconnects. The short answer: Postgres is fast enough for most realtime features, and it's the safer choice when the event describes a database write. Redis has steadier tail latency under load.

What I tested

Everything ran on one 8 vCPU / 16 GB Linux VM on October 30, 2026: PostgreSQL 18.6 (fsync and synchronous_commit on, shared_buffers=512MB), Redis 8.0.2 with persistence off, Node.js 22.20, pg 8.23.1 and ioredis 6.0.0. Subscribers ran in separate Node processes. Publishers sent 100-byte JSON messages with a timestamp, and subscribers measured the time until the message arrived. Each fan-out test ran 10 seconds, and I report the median of 3 runs.

This is one machine with client and server on localhost, so the numbers show how the two compare, not what you'll see across a network.

Delivery latency and fan-out

Subscribers Messages/s Postgres p50 Postgres p99 Redis p50 Redis p99
1 1,000 0.34 ms 0.99 ms 0.17 ms 0.92 ms
1 10,000 0.18 ms 2.60 ms 0.19 ms 1.07 ms
1 25,000 0.39 ms 5.65 ms 0.16 ms 0.69 ms
10 1,000 0.22 ms 2.60 ms 0.17 ms 1.15 ms
10 10,000 0.34 ms 9.83 ms 0.23 ms 0.96 ms
100 1,000 0.52 ms 4.32 ms 0.36 ms 2.39 ms

Neither lost a message in any run. The typical (p50) delivery time is under a millisecond for both. The difference is in the tail: as the message rate or listener count goes up, Postgres p99 climbs faster, and it spikes more between runs. With 10 listeners at 10,000 messages a second, Postgres p99 ranged from 5.8 ms to 79.6 ms across the three runs, while Redis stayed between 0.9 and 2.2 ms. One Postgres run at 25,000 messages a second hit a 113.7 ms p99.

For chat messages, notifications, dashboard refreshes and "your export is ready" events, a few milliseconds at p99 doesn't matter. For a high-rate stream where tail latency matters, Redis is the steadier option.

What NOTIFY costs the writer

Latency is only half of it. The bigger question is what happens to the transactions that send the events. I ran 16 and 64 clients, each doing BEGIN, an INSERT into an events table, and COMMIT, for 10 seconds:

Mode 16 clients (tx/s) 64 clients (tx/s)
Plain INSERT 22,990 25,346
INSERT + pg_notify in the same statement 13,668 to 17,360 14,246 to 17,254
INSERT with an AFTER INSERT trigger calling pg_notify 18,596 13,760 to 15,716
INSERT, commit, then Redis PUBLISH 20,756 19,334 to 19,506

The ranges are separate 10-second runs, with and without a listener connected. These runs were noisy, so read them as a band, not exact figures. Sending a notification in the transaction cost roughly 20 to 45% of commit throughput in my runs. Committing a transaction that sent a NOTIFY adds its messages to a single shared queue, and Postgres holds a database-wide lock while it does that so notifications stay in commit order. The commits that notify take turns.

Publishing to Redis after the commit costs the database much less, because Postgres does nothing extra. But you get that speed by giving up the guarantee in the next section.

Behavior checks: where they really differ

I scripted each of these against both systems. Here's what I found:

Check Postgres LISTEN/NOTIFY Redis Pub/Sub
Message sent inside a transaction that rolls back Not delivered Delivered if you published before the rollback: the subscriber got an event for a row that doesn't exist
When listeners see it Only after COMMIT (0 seen before, 1 after) Immediately on PUBLISH
Payload size 7,999 bytes OK, 8,000 bytes fails with "payload string too long" 1 MB delivered fine
Same channel and payload sent 6 times in one transaction Collapsed: listener got 2 (the 2 distinct payloads) Every publish delivered
Listener offline while 10 messages were sent 0 received after reconnect 0 received after reconnect
Slow consumer A listener stuck in an open transaction holds the queue: 200,000 notifications of 1 KB filled 2.4% of it, cleared after the listener committed Subscriber was disconnected after about 35 MB of unread messages (client-output-buffer-limit pubsub 32mb 8mb 60)

Three of these matter in practice.

NOTIFY is transactional. If the insert rolls back, nobody hears about it. With Redis you either publish before the commit and risk announcing a write that never happened, or publish after it and risk losing the event if the process crashes between COMMIT and PUBLISH. For "this row changed" events, Postgres gets this right without extra code.

Both are fire-and-forget. A listener that is disconnected misses everything sent while it was gone. Neither system queues messages for you. If you need delivery guarantees, use a job queue or Redis Streams. I compared Postgres job queues with BullMQ separately.

Postgres collapses duplicates in a transaction. If you send the same channel and payload twice in one transaction, listeners get it once. That's good for "something changed, refetch" signals and surprising if you use NOTIFY as a counter.

The pattern I use: NOTIFY as a doorbell, the table as the truth

Because notifications can be missed and payloads are capped below 8,000 bytes, I don't put the data in the notification. I write the event to a table, send only the row id in the NOTIFY, and let the server read the row. A reconnecting client catches up from the table using the last id it saw. Here's the whole thing with Node's built-in HTTP server and Server-Sent Events, one LISTEN connection per process, fanned out to browsers by room:

// server.mjs: one LISTEN connection per Node process, fanned out to many SSE clients. // The events table is the source of truth; NOTIFY is only the doorbell. import http from 'node:http'; import pg from 'pg'; const cfg = { connectionString: process.env.DATABASE_URL }; const pool = new pg.Pool(cfg); const rooms = new Map(); // room -> Set<{ res, lastId }> function send(client, row) { const id = Number(row.id); // bigserial comes back from pg as a string: compare numbers, not strings if (id <= client.lastId) return; // already sent (backfill + notify can overlap) client.res.write(`id: ${id}\ndata: ${JSON.stringify(row)}\n\n`); client.lastId = id; } async function backfill(room, client) { const { rows } = await pool.query( 'SELECT id, room, body FROM events WHERE room = $1 AND id > $2 ORDER BY id LIMIT 500', [room, client.lastId], ); for (const row of rows) send(client, row); } async function listen() { const listener = new pg.Client(cfg); let chain = Promise.resolve(); // handle notifications one at a time so ids stay in order listener.on('notification', ({ payload }) => { chain = chain.then(async () => { const { id, room } = JSON.parse(payload); // payload stays tiny: ids only (8000-byte limit) const subs = rooms.get(room); if (!subs?.size) return; const { rows } = await pool.query('SELECT id, room, body FROM events WHERE id = $1', [id]); if (rows[0]) for (const c of subs) send(c, rows[0]); }).catch(console.error); }); listener.on('error', (err) => console.error('listener error:', err.message)); listener.on('end', () => { // Notifications sent while we're disconnected are gone. Reconnect, then backfill from the table. setTimeout(() => listen().catch(console.error), 500); }); await listener.connect(); await listener.query('LISTEN events'); for (const [room, subs] of rooms) for (const c of subs) await backfill(room, c); } http.createServer(async (req, res) => { const url = new URL(req.url, 'http://x'); if (url.pathname === '/events') { const room = Number(url.searchParams.get('room')); const client = { res, lastId: Number(req.headers['last-event-id'] ?? 0) }; res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' }); res.write(': connected\n\n'); if (!rooms.has(room)) rooms.set(room, new Set()); rooms.get(room).add(client); req.on('close', () => rooms.get(room)?.delete(client)); await backfill(room, client); // catch up after a browser reconnect (Last-Event-ID) return; } if (req.method === 'POST' && url.pathname === '/messages') { let raw = ''; for await (const chunk of req) raw += chunk; const { room, text } = JSON.parse(raw); // INSERT and NOTIFY in one statement: the notification is sent only if the row commits const { rows } = await pool.query( `WITH ins AS (INSERT INTO events (room, body) VALUES ($1, $2) RETURNING id, room) SELECT id, pg_notify('events', json_build_object('id', id, 'room', room)::text) FROM ins`, [room, { text }], ); res.writeHead(201, { 'content-type': 'application/json' }).end(JSON.stringify({ id: rows[0].id })); return; } res.writeHead(404).end(); }).listen(process.env.PORT ?? 3400, () => listen().catch(console.error)); 

The table is CREATE TABLE events (id bigserial primary key, room int not null, body jsonb not null).

I tested it with a script that opens three SSE clients (two in room 1, one in room 2), posts 10 messages, then kills the server's LISTEN connection with pg_terminate_backend and posts 3 more while it's down. Results on PostgreSQL 18.6:

  • Each room-1 client got its 5 messages, and the room-2 client got only room-2 messages.
  • After the listener was killed, the server reconnected and the backfill delivered all 3 "gap" messages, with 0 duplicates.
  • A browser reconnecting with Last-Event-ID got exactly the 3 messages it had missed.

The bug the test caught. My first version compared row.id <= client.lastId directly. pg returns bigint and bigserial columns as strings, because they can be larger than JavaScript numbers can hold exactly. Once ids reached two digits, "10" <= "8" was true as a string comparison. The room-2 client silently dropped its 5th message, and the backfill after the reconnect sent nothing. Converting with Number() fixed both. If your ids may go past 2^53, compare BigInts instead.

Production notes

  • Use a direct connection for LISTEN. PgBouncer in transaction pooling mode doesn't support LISTEN, because your session isn't kept between transactions. Keep one dedicated connection per app instance for listening and use the pool for everything else.
  • Never sit in an open transaction on the listening connection. Notifications are delivered between transactions. A listener that stays inside a transaction holds the shared queue for everyone, and the queue is 8 GB by default. Watch SELECT pg_notification_queue_usage().
  • Send ids, not data. The payload limit is just under 8,000 bytes, and a small payload keeps the queue small.
  • Plan for missed messages. Both Postgres and Redis drop messages for anyone who isn't connected. Backfill from a table, or use a queue when every event must be processed.
  • Watch notify-heavy write paths. If one table does thousands of commits a second, test the throughput cost above on your own hardware before adding a trigger that notifies on every row.

Which one should you use?

Use Postgres LISTEN/NOTIFY when the events come from your own database writes: new chat messages, order status changes, "this job finished", cache invalidation. You get transactional delivery and no extra service to run. That's how I'd add realtime updates to a NestJS app with Server-Sent Events that already uses Postgres.

Use Redis Pub/Sub when you need steadier latency at high message rates, payloads above 8 KB, or events that don't come from a database write, like presence, typing indicators or live cursors. If you already run Redis for BullMQ job queues, adding Pub/Sub costs nothing extra.

Use neither when every message must be processed. That's a job queue or a stream.

FAQ

Is Postgres LISTEN/NOTIFY fast enough for realtime apps?

For most apps, yes. In my test on PostgreSQL 18.6 the median delivery time was 0.18 to 0.52 ms from 1 to 100 listeners at up to 25,000 messages a second, with no lost messages. Its p99 latency was higher and less steady than Redis Pub/Sub under load, up to about 10 ms at 10,000 messages a second with 10 listeners.

Does NOTIFY slow down writes?

It can. With 16 to 64 clients doing single-row inserts, sending pg_notify in the same transaction ran at about 55 to 80% of plain insert throughput in my runs. Commits that send notifications append to one shared queue in commit order, so they take turns.

Is a NOTIFY sent if the transaction rolls back?

No. Notifications are delivered only when the transaction commits, and a rolled-back transaction sends nothing. Redis Pub/Sub has no link to your database transaction, so a publish before the rollback still reaches subscribers.

What is the maximum NOTIFY payload size?

The payload must be shorter than 8,000 bytes in a default PostgreSQL build. In my test a 7,999-byte payload worked and 8,000 bytes failed with 'payload string too long'. Send an id and read the row instead.

Does LISTEN/NOTIFY work with PgBouncer?

Not in transaction pooling mode, because LISTEN needs the same session to stay connected. Use one direct database connection per app instance for LISTEN and the pool for everything else.

Originally published on fabwaseem.com.