Your application issues LISTEN job_events, the command succeeds, and PostgreSQL’s logs show NOTIFY firing on schedule. But the listener never receives anything. No error in the application. No error in PgBouncer. No metric anywhere that moves. The feature worked in staging, worked before you put PgBouncer in front of the database, and now it silently does nothing.
This is the pool mode mismatch failure pattern, and LISTEN/NOTIFY is its most confusing variant because the failure is completely silent. Unlike prepared statements (which at least produce “prepared statement does not exist” errors), a lost LISTEN registration produces no error at all. The notification is delivered to a backend connection your client no longer holds, or to whichever client happens to hold that connection next.
The root cause is almost always the same: the listener connection is going through a pool running in transaction pooling mode. The fix is routing listener connections through session pooling, or taking them off the pooled path entirely. The broader mental model is covered in How PgBouncer actually works in production; this article is narrowly about confirming and fixing the LISTEN/NOTIFY case.
What this means
PostgreSQL’s LISTEN/NOTIFY is session-level state. When a client runs LISTEN channel_name, the registration lives on that specific backend connection, the server process on the PostgreSQL side. Notifications for that channel are delivered to that backend, which forwards them to the client attached to it. The registration survives as long as the session survives.
Transaction pooling breaks the assumption underneath all of this. In transaction mode, PgBouncer holds the server connection only for the duration of one transaction, then returns it to the pool and may hand it to a completely different client. From the client’s perspective the connection looks continuous. From PostgreSQL’s perspective, the session state the client created, including the LISTEN registration, is now attached to a server connection that client no longer owns.
The sequence:
flowchart TD
A[Client sends LISTEN via PgBouncer] --> B[PgBouncer assigns server conn S1]
B --> C[LISTEN registered on backend S1]
C --> D[Transaction ends - S1 returned to pool]
D --> E[S1 reassigned to another client]
E --> F[NOTIFY arrives at backend S1]
F --> G[Delivered to whoever holds S1 - never to the listener]Note the asymmetry that trips people up during testing: NOTIFY works fine through transaction pooling. It is a single statement with no session state behind it, so any backend connection can execute it. LISTEN does not. Operators test the publish path, see notifications flowing, and conclude pub/sub works. It does not. PgBouncer’s own SQL feature map documents this explicitly: LISTEN is supported with session pooling and never with transaction pooling, while NOTIFY works with both.
The second trap: this often “works” in development or under light load. If only one client is using the pool, PgBouncer may keep handing back the same server connection, and the registration appears to survive. The failure only shows up under concurrent load in production, which is why it survives testing.
Common causes
| Cause | What it looks like | First thing to check |
|---|---|---|
| Listener connected through a transaction-mode pool | LISTEN succeeds, NOTIFY fires in PG logs, listener receives nothing | SHOW CONFIG; and per-database pool_mode |
| Whole deployment switched to transaction mode without auditing app code | Multiple session-dependent features broken (LISTEN, advisory locks, temp tables, SET variables) | Application error logs for “does not exist” or missing session state |
| Listener shares a connection pool with regular query traffic | Notifications arrive intermittently, worse under load | Whether the listener uses its own connection or the shared app pool |
| ORM or client library transparently routes all connections through one DSN | Everything points at the same PgBouncer database alias | The listener’s connection string versus the rest of the app |
| Client-side pooler also reclaims connections | Same symptom even in session mode if the app pool recycles the physical connection | Client library pool settings for connection lifetime and reuse |
One cause that is NOT PgBouncer: if the NOTIFY runs inside a transaction that rolls back, the notification is never sent. That is standard PostgreSQL behavior, independent of pooling. Rule it out before blaming the pooler.
Quick checks
All read-only. Run them against the PgBouncer admin console.
# 1. Check the global pool mode
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer -Atc "SHOW CONFIG;" | grep pool_mode
# 2. Check per-database pool settings (per-database overrides beat the global default)
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer -Atc "SHOW DATABASES;"
# 3. Confirm pool turnover is happening on the listener's pool
# High sv_active churn with the listener connected means the backend is being recycled
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer -Atc "SHOW POOLS;"
# 4. Bypass PgBouncer entirely: run LISTEN/NOTIFY against PostgreSQL directly.
# If this works (it will), the pooler path is the problem.
# Do this in two interactive psql sessions, not psql -c: LISTEN only
# receives notifications while the session stays open.
# Session A: psql -h <postgres-host> -U <user> -d <db>
# LISTEN test_chan;
# Session B: NOTIFY test_chan, 'hello';
# Session A should print the asynchronous notification.
# 5. Check what the listener connection actually looks like from PgBouncer's side
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer -Atc "SHOW CLIENTS;"
How to diagnose it
Confirm the symptom is a lost registration, not a missing NOTIFY. Check PostgreSQL’s logs or run a known-good listener connected directly to PostgreSQL (check 4 above). If a direct listener receives the notification and the pooled listener does not, you have confirmed the pooling path is dropping it.
Identify which PgBouncer database entry the listener uses. Get the listener’s connection string from the application config. Map it to a
[databases]entry inpgbouncer.ini.Check the effective pool_mode for that entry. Pool mode can be set globally, per database in the
[databases]section, and per user in the[users]section.SHOW CONFIG;shows the global default,SHOW DATABASES;shows per-database settings, andSHOW USERS;shows per-user settings. Any one of these set totransaction(orstatement) on the listener’s path is the bug.Rule out a client-side pool doing the same thing. Some client libraries run their own connection pool in front of PgBouncer and hand the listener a different physical connection after idle periods. If the listener uses a shared application pool, the registration can be lost client-side even with PgBouncer in session mode. The listener must hold one dedicated, long-lived connection end to end.
Verify with a load test. Single-client testing can pass because the same backend gets reused. Reproduce with concurrent traffic on the pool and watch the notification stop arriving.
Metrics and signals to monitor
The hard truth: there is no PgBouncer metric for missed notifications. PgBouncer’s SHOW commands expose pool and traffic counters, not session-state loss, so detection is indirect.
| Signal | Why it matters | Warning sign |
|---|---|---|
pool_mode (SHOW CONFIG / SHOW DATABASES / SHOW USERS) | The actual root cause; should be part of config-drift checks | transaction on a database entry serving a listener |
Server assignment rate (avg_server_assignment_count) | Pool turnover; a listener’s pool should show near-zero turnover in session mode | High assignment rate on the pool the listener uses |
SHOW CLIENTS connection age for the listener | The listener should hold one old, stable connection | Listener connection cycling (young connect_time repeatedly) |
| Application-side notification lag | The only true measure of the failure | Rising time between NOTIFY and handler execution |
The reliable detector is an end-to-end canary: have the application timestamp when it sends a NOTIFY and when the listener processes it, and alert on the lag or on missed heartbeats. Infrastructure metrics will never see this failure.
Fixes
Route listeners through a session-mode database alias
The standard fix. Add a second entry in pgbouncer.ini pointing at the same PostgreSQL database but with session pooling:
[databases]
mydb = host=pg-primary dbname=mydb pool_mode=transaction
mydb_session = host=pg-primary dbname=mydb pool_mode=session
Point only the listener connection at mydb_session. Everything else keeps the efficiency of transaction pooling. Issue a RELOAD in the admin console to apply. Tradeoffs: the listener pins one server connection for its entire lifetime, consuming one slot from its pool and one PostgreSQL connection slot. Size the session-mode pool accordingly, typically small, since only listeners use it. If you have many listener processes, each holds a backend connection, which partially defeats pooling for that workload. That is the cost of correctness here.
The same approach works per user if you would rather separate by role than by database alias.
Bypass PgBouncer for the listener
Give each process that needs LISTEN/NOTIFY one dedicated connection straight to PostgreSQL, and route all other traffic through PgBouncer in transaction mode. This is the pattern several production queue systems document: pooled connections for inserts and job fetches, one raw connection for the notification listener.
Tradeoffs: the listener’s connection is not protected by the pooler, so you handle reconnection, failover, and backend slot accounting yourself. Keep the count of direct connections small and include them in your PostgreSQL max_connections budget.
Move the wakeup off LISTEN/NOTIFY entirely
If neither option fits (for example, a serverless runtime where no process can hold a long-lived connection), the durable alternative is to treat NOTIFY as a hint only: workers poll an outbox or queue table on a short interval, and notifications just make the poll happen sooner. A missed notification then costs latency, not correctness. This is an application change, so it is a redesign rather than a fix, but it removes the session-state dependency completely.
Prevention
- Audit before switching pool modes. Session-dependent features (LISTEN/NOTIFY, advisory locks, temp tables, prepared statements, SET variables) must be inventoried before any database or user moves to transaction pooling. The failure is silent, so “deploy and watch” does not find it.
- Test under concurrency. A single-client smoke test passes even with the wrong pool mode. LISTEN/NOTIFY verification must run while other clients churn the pool.
- Treat pool_mode as a reviewed config surface. Per-database and per-user overrides in
pgbouncer.inishould be visible in code review, andSHOW CONFIG/SHOW DATABASESoutput should be diffed after everyRELOAD. - Run a notification canary. A periodic NOTIFY with an end-to-end lag measurement is the only alert that catches this class of regression, including regressions introduced by client library upgrades.
How Netdata helps
Netdata cannot see a missed notification directly, because no counter exists for it in PgBouncer. What it can do is make the surrounding state visible so you confirm the root cause in minutes instead of hours:
- Pool state per (database, user): sv_active, sv_idle, and client counts per pool, so you can see that the listener’s pool is churning connections at transaction-mode rates.
- Pool turnover signals: server assignment rate as a continuous time series, so “the backend connection keeps getting recycled” is a graph, not a theory.
- Config and state visibility: collecting
SHOW POOLS,SHOW DATABASES, andSHOW CONFIGover time means pool_mode changes show up as deploy-correlated events rather than mysteries. - Correlated saturation context: if a misdiagnosed “fix” (like moving everything to session mode) starts exhausting the pool, cl_waiting, maxwait, and avg_wait_time show the tradeoff immediately. See PgBouncer pool exhaustion for that failure mode.
- Restart and reload correlation: stats resets and config reloads are visible against metric continuity, which matters because a
RELOADis exactly when a pool_mode regression gets introduced.
The honest limitation: the definitive detection of this bug remains an application-side notification-lag canary. Use Netdata for the infrastructure half of the correlation.
Related guides
- How PgBouncer actually works in production: a mental model for operators
- PgBouncer monitoring checklist: the signals every connection pooler needs
- PgBouncer monitoring maturity model: from survival to expert
- PgBouncer pool exhaustion: clients queue, wait times climb, and the retry cascade
- PgBouncer pool utilization high: sv_active approaching pool_size before clients queue
- PgBouncer avg_wait_time high: the latency the pool itself is injecting
- PgBouncer maxwait high: the oldest client waiter and how close it is to timing out
- PgBouncer query_wait_timeout: clients disconnected after waiting too long for a connection
- PgBouncer reserve pool activation: overflow capacity that hides an undersized pool
- PgBouncer pool_size sizing: matching pool capacity to transaction time and throughput
- PgBouncer sv_idle at zero: no headroom and one slow query from a cascade
- PgBouncer capacity planning: runway for pools, clients, and PostgreSQL slots






