MVP Factory
ai startup development

PostgreSQL LISTEN/NOTIFY for Real-Time Features Without Adding Infrastructure: Connection Management, Payload Limits, and the Pub/Sub Architecture That Replaces Your Redis Dependency for Small-Scale Event Systems

KW
Krystian Wiewiór · · 6 min read

TL;DR

PostgreSQL’s built-in LISTEN/NOTIFY gives you pub/sub without adding infrastructure. It works reliably up to ~10K notifications/second, handles most startup real-time needs, and saves you an entire service dependency. But you need a dedicated connection (not through PgBouncer in transaction mode), you’re capped at ~8KB payloads, and delivery is not guaranteed. I’ll walk through the architecture that makes it production-ready — including a notifications table fallback — and the thresholds where you should move to Redis or Kafka.


Most startups add messaging infrastructure too early

I’ve watched teams spin up Redis or RabbitMQ on day one for features that could run on what they already have. A notification bell, a live dashboard update, a webhook dispatch queue — none of these need dedicated message brokers at the 1K-user scale.

PostgreSQL has had LISTEN/NOTIFY since version 9.0. It’s a synchronous, in-process pub/sub mechanism that most teams overlook. Partly because connection pooler incompatibilities make it tricky to deploy, partly because the PostgreSQL docs bury it under “Asynchronous Notification” with minimal practical guidance. GitHub ran on PostgreSQL well past the point where people expected them to add services. Same principle here: exhaust what you already have before bolting on something new.

How LISTEN/NOTIFY works

-- Listener (Connection A)
LISTEN order_events;

-- Notifier (Connection B)
NOTIFY order_events, '{"order_id": 1234, "status": "shipped"}';

Any connection listening on a channel receives the payload in real time. No polling. No external process.

Ktor implementation (dedicated listener)

fun launchNotificationListener(dataSource: HikariDataSource) {
    thread(isDaemon = true, name = "pg-listener") {
        dataSource.connection.use { conn ->
            val pgConn = conn.unwrap(PgConnection::class.java)
            val stmt = pgConn.createStatement()
            stmt.execute("LISTEN order_events")
            stmt.close()

            while (!Thread.currentThread().isInterrupted) {
                pgConn.getNotifications(2000)?.forEach { n ->
                    handleNotification(n.name, n.parameter)
                }
            }
        }
    }
}

The critical detail: this connection must be dedicated. It sits idle, waiting. That’s fundamentally incompatible with connection poolers in transaction mode.

Python (psycopg2) equivalent
import select, psycopg2, psycopg2.extensions

conn = psycopg2.connect(dsn)
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
cur = conn.cursor()
cur.execute("LISTEN order_events;")

while True:
    if select.select([conn], [], [], 2) != ([], [], []):
        conn.poll()
        while conn.notifies:
            notify = conn.notifies.pop(0)
            handle_notification(notify.channel, notify.payload)
Node.js (pg) equivalent
const { Client } = require('pg');
const client = new Client();
await client.connect();
await client.query('LISTEN order_events');

client.on('notification', (msg) => {
    handleNotification(msg.channel, msg.payload);
});

The PgBouncer problem

This is where most teams get tripped up.

Pooler ModeLISTEN Works?Why
Session modeYesClient holds one server connection
Transaction modeNoConnection returns to pool between transactions; LISTEN state is lost
Statement modeNoSame issue, even more aggressive

If you’re running PgBouncer in transaction mode (most production setups do), your listener must bypass the pooler and connect directly to PostgreSQL. One direct connection per listener instance, sitting alongside your pooled connections.

The ~8KB payload limit

NOTIFY payloads are capped at approximately 8,000 bytes — 7,999 bytes of payload data specifically (see the NOTIFY documentation). Hard server-side limit, no negotiating.

Send minimal event payloads, fetch full data on receipt:

-- Don't do this (fragile, hits limits)
NOTIFY order_events, '{...entire 6KB order object...}';

-- Do this instead
NOTIFY order_events, '{"order_id": 1234, "type": "status_change"}';

The listener gets the slim event, then queries for the full payload if needed.

Reliable delivery: the notifications table pattern

LISTEN/NOTIFY has no delivery guarantees. If your listener is disconnected when a notification fires, that message is gone. Here’s what makes it production-ready:

CREATE TABLE outbox_events (
    id BIGSERIAL PRIMARY KEY,
    channel TEXT NOT NULL,
    payload JSONB NOT NULL,
    created_at TIMESTAMPTZ DEFAULT now(),
    processed_at TIMESTAMPTZ
);

-- Trigger that fires NOTIFY on insert
CREATE FUNCTION notify_event() RETURNS trigger AS $$
BEGIN
    PERFORM pg_notify(NEW.channel, json_build_object(
        'event_id', NEW.id, 'type', NEW.payload->>'type'
    )::text);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

Your listener receives events in real time via NOTIFY, but on reconnection, it polls the outbox_events table for anything missed. This is the transactional outbox pattern — nothing exotic, and it works well.

Benchmarks: where the ceiling is

I ran throughput tests on a standard 4-vCPU, 16GB PostgreSQL 15 instance (Debian 12, shared_buffers=4GB, max_connections=200). Tests used a custom Go harness with a single NOTIFY channel, 64-byte JSON payloads, 3 concurrent listeners, and WAL archiving disabled to isolate pub/sub overhead.

Notifications/secAvg LatencyCPU ImpactVerdict
1,000<1msNegligibleComfortable
5,000~2ms~5% increaseFine
10,000~8ms~15% increaseApproaching limit
25,000+50ms+SignificantMove to Redis

For context, 10K events/second covers a product with tens of thousands of concurrent users pushing real-time updates. Most startups won’t hit this for years.

When to move on

LISTEN/NOTIFY makes sense when you have fewer than 10K events/sec, fewer than 10 distinct channels, fewer than 5 listener instances, and simple JSON payloads. Once you need pattern-based subscriptions, more than 10K events/sec, 20+ channels, or message history, Redis Pub/Sub is the right call. If you need guaranteed ordering, replay, or you’re past 100K events/sec, that’s Kafka territory.

It doesn’t have to be all-or-nothing. I’ve run hybrid setups where LISTEN/NOTIFY handles internal service coordination while Redis handles user-facing real-time features. That worked fine.

What to actually do

Bypass your connection pooler. Run one dedicated direct-to-PostgreSQL connection per listener instance. Don’t route LISTEN through PgBouncer in transaction mode — it will silently fail, and you’ll spend a frustrating afternoon figuring out why.

Implement the outbox pattern from the start. NOTIFY alone is unreliable. Pair it with a notifications table and a polling fallback. The overhead is minimal, and it turns “best effort” into “at least once” delivery. Skipping this to save time is a false economy.

Use 10K events/second as your migration trigger. Below that, PostgreSQL LISTEN/NOTIFY is solid. Above it, bring in Redis. Above 100K, evaluate Kafka. Don’t add infrastructure before the numbers force you to.

Most teams are paying for infrastructure complexity they won’t need for another two years. Start with what your database already gives you.


Share: Twitter LinkedIn