
Postgres LISTEN/NOTIFY in Production: Safe Reconnects & SSE

Postgres LISTEN/NOTIFY in Production: Reconnects, Races, and SSE
Meta description: Postgres LISTEN/NOTIFY gives you pub/sub without extra infra. Here's the production pattern: one shared connection, reconnect logic, and SSE — with the race conditions to watch for.
If your app needs to push live updates to connected clients — a job progress bar, a balance that changes when a background worker finishes, a "this record was deleted elsewhere" toast — the reflex is to reach for Redis Pub/Sub or a message broker. But if you already run PostgreSQL, it ships with a pub/sub primitive built in: LISTEN / NOTIFY. No extra infrastructure, no extra failure domain.
What is Postgres LISTEN/NOTIFY? It's a built-in pub/sub mechanism: one session runs pg_notify(channel, payload) inside a transaction, and any other session that has issued LISTEN <channel> on a persistent connection receives the payload once that transaction commits. There's no persistence and no delivery guarantee — it's a live signal, not a queue — so it fits best as a "something changed, go re-check" nudge rather than a system of record.
This post walks through the implementation we actually run in production: a single shared LISTEN connection multiplexing many logical channels, wired into per-client Server-Sent Events (SSE) streams. Along the way: the reconnect logic and the race conditions that come with it.
How Postgres LISTEN/NOTIFY works
Postgres gives you two SQL-level building blocks:
SELECT pg_notify('job_status_123', '{"status":"done"}');
and, on a persistent connection:
LISTEN job_status_123;
Any session that has issued LISTEN on a channel receives the payload once the transaction that ran pg_notify commits, not the instant the call executes. Multiple identical notifications on the same channel/payload fired within one transaction get collapsed into one delivery. Several constraints shape everything that follows (see the Postgres NOTIFY reference for the authoritative spec):
- Payload is capped at 8000 bytes. Fine for status pings, not for shipping large blobs.
- Channel names are Postgres identifiers, truncated at 63 characters. Patterns like
job_status_<uuid>need to fit under that limit or get silently truncated and collide. - No persistence, no delivery guarantee. If nobody is listening when
pg_notifyfires, the message is gone. This is a live signal, not a queue: any client relying on it needs a way to independently reconcile state, e.g. re-fetch from the DB. - The notification queue itself is bounded (8GB by default, per the Postgres async messaging docs). If a listening session sits idle inside an open transaction, it stops draining the queue for everyone, and
pg_notifystarts erroring once the queue fills. Keep LISTEN sessions out of long-running transactions.
Because LISTEN requires a session-scoped, long-lived connection, and most apps talk to Postgres through a connection pool, you can't just LISTEN from your normal query pool. Pooled connections get recycled and your subscription vanishes with them. You need one connection dedicated to listening, held open for the life of the process.
This also rules out transaction-mode poolers like pgbouncer or RDS Proxy for the LISTEN connection specifically. They can hand your session's underlying socket to a different client between transactions, which silently kills the subscription. The dedicated LISTEN connection needs to bypass the pooler and go straight to Postgres. If you run multiple app instances, each one holds its own LISTEN connection and each receives every NOTIFY independently, which is usually what you want: every instance can push updates to its own locally-connected clients.
One connection, many channels
Rather than open a Postgres connection per subscriber, a single physical LISTEN connection can multiplex an arbitrary number of logical channels. In-process, that's just a map from channel name to a set of callbacks:
class NotificationHub { private client: ReturnType<typeof postgres> | null = null; private readonly listeners = new Map<Channel, Set<MessageCallback>>(); private readonly unlistenFns = new Map<Channel, () => Promise<void>>(); ...
The connection itself is configured to never idle out and to identify itself for observability:
this.client = postgres({ ...getAppConnectionConfig(), max: 1, idle_timeout: 0, connect_timeout: 10, connection: { application_name: "app-listen" }, onclose: () => { /* ... */ }, });
This pool is deliberately capped at one connection: it exists purely to host a dedicated LISTEN session, not to serve queries. Mixing it into a general-purpose pool would mean losing subscriptions every time the pool recycles a connection.
Handling reconnects: capped exponential backoff
A long-lived TCP connection to a database will eventually drop: network blips, database failover, load balancer idle timeouts. The interesting design question isn't "will it disconnect" but "what happens to every registered channel when it does."
onclose: () => { if (!this.closed) { dbLogger.warn("LISTEN connection closed unexpectedly"); this.setState("disconnected"); this.scheduleReconnect(); } },
Reconnect uses a capped exponential backoff so a flapping database doesn't get hammered with reconnect attempts:
const RECONNECT_DELAYS = [1_000, 2_000, 4_000, 8_000, 15_000] as const; private scheduleReconnect(): void { if (this.closed || this.reconnectTimer) return; const delayIndex = Math.min(this.reconnectAttempt, RECONNECT_DELAYS.length - 1); const delay = RECONNECT_DELAYS[delayIndex]; this.reconnectAttempt++; this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; void this.connect(); }, delay); }
reconnectAttempt resets to 0 on a successful connect(), so backoff only escalates across consecutive failures. A single flaky reconnect doesn't leave future reconnects artificially delayed.
On reconnect, every channel that had at least one active subscriber gets re-LISTENed from scratch:
const channels = Array.from(this.listeners.keys()); const failedChannels: Channel[] = []; for (const channel of channels) { try { await this.listenToChannel(channel); } catch (err) { failedChannels.push(channel); } } if (failedChannels.length > 0) { this.setState("disconnected"); this.scheduleReconnect(); return; }
Note the all-or-nothing treatment: if even one channel fails to re-LISTEN, the whole reconnect is considered failed and retried. A hub that's "half connected," silently missing notifications on some channels, is worse than one that's visibly disconnected and retrying. At least then the failure isn't invisible to callers.
The unsubscribe race, solved by ordering, not locking
Multiple parts of an app can subscribe to the same channel concurrently (two browser tabs watching the same job, for instance). The tricky moment is when the last subscriber for a channel unsubscribes at the same time a new subscriber for that same channel is arriving:
unsubscribe: async () => { for (const channel of channels) { const callbacks = this.listeners.get(channel); if (!callbacks) continue; callbacks.delete(onMessage); if (callbacks.size === 0) { this.listeners.delete(channel); const unlisten = this.unlistenFns.get(channel); // Delete before awaiting so a concurrent subscribe() for this // channel calls listenToChannel() again. PostgreSQL silently // ignores a duplicate LISTEN on the same connection, so the race // is harmless — the hub will have an active LISTEN either way. this.unlistenFns.delete(channel); if (unlisten) { try { await this.timeoutUnlisten(unlisten); } catch { /* timed out or connection gone — safe to ignore */ } } } } },
The naive version would await UNLISTEN first, then clean up bookkeeping. But between "decide to unlisten" and "UNLISTEN finishes," a new subscriber could arrive, see the channel already has a bookkeeping entry, and skip re-LISTENing. That leaves the hub with no active LISTEN for a channel it thinks it's subscribed to.
By deleting the map entry before awaiting UNLISTEN, a concurrent subscribe() call always sees "no entry" and issues its own LISTEN unconditionally. Postgres treats a duplicate LISTEN on the same session as a no-op, so the worst case is two harmless LISTEN calls in flight instead of a silently dropped subscription. When you can't easily lock across an async boundary, look for an operation idempotent enough to make the race safe by construction.
Callers should never worry about connection state
The public subscribe() API registers callbacks unconditionally, regardless of whether the underlying Postgres connection is currently up. It only rejects if an opportunistic LISTEN call fails outright while attempting an already-connected session, and that case is recovered by the reconnect logic on its next attempt:
async subscribe(channels: Channel[], onMessage: MessageCallback): Promise<Subscription> { // Register callbacks regardless of connection state for (const channel of channels) { let callbacks = this.listeners.get(channel); if (!callbacks) { callbacks = new Set(); this.listeners.set(channel, callbacks); } callbacks.add(onMessage); } if (this.state === "connected" && this.client) { for (const channel of channels) { await this.listenToChannel(channel); // no-op if already LISTENing } } if (this.state === "disconnected" && !this.connecting && !this.reconnectTimer) { void this.connect(); } return { unsubscribe: async () => { /* ... */ } }; }
Callbacks are registered in memory first; the actual LISTEN happens opportunistically if connected, or gets picked up automatically on the next successful reconnect. Every consumer of the hub stays decoupled from the connection's health. They just subscribe, and messages show up when the pipe is open.
Wiring it into Server-Sent Events
The hub by itself is transport-agnostic; a thin SSE layer turns it into per-client streams with keep-alives and cleanup on disconnect:
const stream = new ReadableStream({ start(controller) { let subscription: Subscription | null = null; const onAbort = () => { void cleanup(); }; options.request.signal.addEventListener("abort", onAbort); keepAliveInterval = setInterval( () => safeEnqueue(`: ping\n\n`), keepAliveMs, ); const init = async () => { await options.onStart(safeEnqueue, () => { void cleanup(); }); if (!options.subscribe || isClosed) return; const { channels, onMessage } = options.subscribe; unsubscribeState = onHubStateChange((state) => { safeEnqueue( `data: ${JSON.stringify({ type: "info", liveUpdates: state === "connected" })}\n\n`, ); }); subscription = await subscribe(channels, (ch, message) => { if (!isClosed) onMessage(ch, message, safeEnqueue, () => { void cleanup(); }); }); if (options.onAfterSubscribe) { await options.onAfterSubscribe(safeEnqueue, () => { void cleanup(); }); } }; init().catch(() => { void cleanup(); }); }, cancel() { return cleanupFn?.(); }, });
Two details worth calling out.
The hub's connection state is surfaced to the client. When onHubStateChange reports "disconnected", an {"type":"info","liveUpdates":false} frame goes out over SSE so the UI can show "reconnecting..." instead of silently going stale. Since NOTIFY gives no delivery guarantee, telling the client when it might be missing updates is almost as important as delivering the updates themselves.
There's also a read-then-subscribe race window. onStart typically does an initial DB read to send current state, and only afterwards does the code call subscribe() to start receiving live updates. If a NOTIFY fires in the gap between that read and the LISTEN actually being established, it's lost for good: the classic "check-then-watch" race in any pub/sub system. The onAfterSubscribe hook closes that window. After the subscription is confirmed active, it re-checks state one more time, so any update that landed in the gap gets picked up on the second read instead of being silently missed. The client-side handler needs to tolerate receiving the same update twice (once from the catch-up read, once from a live NOTIFY that arrives moments later). Treat updates as idempotent, not as a strictly-once stream.
When this pattern fits
Postgres LISTEN/NOTIFY works well when:
- Update volume is modest (dashboards, job status, per-user notifications), not a high-throughput event bus.
- Clients treat NOTIFY as a nudge to re-sync, not as the sole source of truth. Always have a path to reconstruct state via a normal query if a notification is missed.
- You want one fewer piece of infrastructure to run and monitor.
It's the wrong tool when you need guaranteed delivery, durable queues, replay, or very high fan-out. That's what a real message broker is for. But for "tell connected clients something changed," Postgres has had a message bus built in the whole time.
Frequently Asked Questions
Is Postgres LISTEN/NOTIFY reliable?
No. LISTEN/NOTIFY is a transient, in-memory pub/sub: messages have no persistence or delivery guarantee, so clients should re-check state via normal queries if reliability matters.
When should I use LISTEN/NOTIFY instead of Redis Pub/Sub?
Use LISTEN/NOTIFY when you already run Postgres and update volume is modest; choose Redis or a broker for high fan-out, durability, or replay.
Can LISTEN/NOTIFY work through pgbouncer or RDS Proxy?
Not reliably. Transaction-mode poolers can break a long-lived LISTEN session by reusing sockets; the dedicated LISTEN connection must bypass such poolers.
What are payload limits for pg_notify?
Payloads are limited to about 8000 bytes; send identifiers and re-fetch full records rather than embedding large blobs.
How do you avoid missing notifications during reconnects or subscriptions?
Keep a single dedicated LISTEN connection, re-issue LISTENs on reconnect for active channels, and use a read-after-subscribe check to close the read-then-watch race window.
Want to avoid extra infra?
We can help you evaluate when Postgres LISTEN/NOTIFY is appropriate and implement it safely to reduce operational overhead.





