Multigres Supports Listen/Notify Across Pooled Connections

kiwicopple1 pts0 comments

How Multigres Supports LISTEN/NOTIFY Across Pooled Connections | Blog | Multigres

Postgres LISTEN/NOTIFY has a scaling problem: the more clients listen, the slower every notification gets — by orders of magnitude once you're into the thousands. Multigres keeps that line flat, even though it pools connections away from clients — the one thing the feature depends on. How it pulls that off, and the edge cases you have to get right to make it behave exactly like Postgres, is what this post is about.

A quick refresher on LISTEN/NOTIFY

LISTEN/NOTIFY is Postgres's built-in publish/subscribe mechanism. A session subscribes to a named channel:

LISTEN events;

Another session publishes to it:

NOTIFY events, 'cache invalidated: user:42';

Every session currently listening on events receives an asynchronous message containing the notifying backend's PID, the channel name, and the payload. On the wire, the server pushes it to the client as a NotificationResponse (message type 'A') outside the normal request/response flow. Applications lean on this for cache invalidation, job queues, and realtime fan-out precisely because it avoids polling.

A LISTEN registers the current session as a listener, and that registration is cleared the moment the session ends; it's per-connection state. Delivery, on the other hand, is broader: a NOTIFY reaches every session in the same database that is listening on the channel, no matter which backend connection it sits on. Postgres implements this with a single cluster-wide queue on disk (pg_notify/), tags each notification with the sender's database OID to keep it database-local, and signals every listening backend to come read it. In short, registration is per-session, delivery is per-database .

Why pooling breaks it

Notice what Postgres ties the feature to: a session. And a pooler's whole job is to stop clients from owning one. Multigres deliberately decouples a client connection from any single Postgres backend, a client's queries can land on a different backend connection from one statement to the next. With no durable session to live in, LISTEN has nothing to attach to:

LISTEN can't run on a borrowed connection. If LISTEN events executed on whatever backend happened to be free, the very next statement could be routed elsewhere, and the registration would be stranded on a connection the client no longer holds.

Notifications would arrive where the client can't see them. Postgres would faithfully deliver to whatever backend ran the LISTEN, but in a pooler that backend isn't the client's to keep. The message would land on a connection that has already been handed back to the pool and reused by someone else.

Postgres is not the problem here. It delivers a notification across different backend connections perfectly well, as long as they share the same database on the same instance. Multigres leans on exactly that behavior — but only after solving the session-ownership problem above. The naive fix (pin every listening client to a dedicated backend connection) throws away pooling for exactly the workloads (lots of long-lived listeners) where pooling matters most. We needed listeners to stay poolable.

The shared listener connection

The core idea: clients never own a Postgres session for listening. The pooler does.

Each pooler maintains a single, long-lived listener connection to its Postgres backend, separate from the query pool. It is acquired lazily (the first time any client issues a LISTEN, not before) and it is reserved, so the pool treats it as permanently checked out for as long as anyone is listening and never recycles it out from under us.

This one connection listens on behalf of every client connected to that pooler. The listener tracks channels with a refcount: the first subscriber to a channel triggers a real LISTEN channel on the backend, and the last unsubscribe triggers the matching UNLISTEN, so we issue exactly one backend subscription per distinct channel regardless of how many clients want it.

Because the listener connection has to send LISTEN/UNLISTEN commands while simultaneously receiving a continuous stream of notifications, it runs with a split read/write model over the single socket: a reader goroutine drains incoming messages while an event loop writes subscription changes. That avoids tearing down and re-establishing the connection every time the set of channels changes, which would itself be a window for lost notifications.

Now NOTIFY falls out naturally. Multigres doesn't treat it as anything special at execution time — the planner routes it to a single fixed target as an ordinary query. That fixed routing is deliberate: a Postgres notification never crosses from one instance to a separate one, so every NOTIFY has to land on the same instance the listener connection is watching for the two to meet. The NOTIFY runs on a normal backend connection, and Postgres delivers the notification to every session in that database listening on the channel,...

listen connection backend postgres session notify

Related Articles