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

CauseWhat it looks likeFirst thing to check
Listener connected through a transaction-mode poolLISTEN succeeds, NOTIFY fires in PG logs, listener receives nothingSHOW CONFIG; and per-database pool_mode
Whole deployment switched to transaction mode without auditing app codeMultiple 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 trafficNotifications arrive intermittently, worse under loadWhether the listener uses its own connection or the shared app pool
ORM or client library transparently routes all connections through one DSNEverything points at the same PgBouncer database aliasThe listener’s connection string versus the rest of the app
Client-side pooler also reclaims connectionsSame symptom even in session mode if the app pool recycles the physical connectionClient 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

  1. 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.

  2. Identify which PgBouncer database entry the listener uses. Get the listener’s connection string from the application config. Map it to a [databases] entry in pgbouncer.ini.

  3. 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, and SHOW USERS; shows per-user settings. Any one of these set to transaction (or statement) on the listener’s path is the bug.

  4. 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.

  5. 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.

SignalWhy it mattersWarning sign
pool_mode (SHOW CONFIG / SHOW DATABASES / SHOW USERS)The actual root cause; should be part of config-drift checkstransaction 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 modeHigh assignment rate on the pool the listener uses
SHOW CLIENTS connection age for the listenerThe listener should hold one old, stable connectionListener connection cycling (young connect_time repeatedly)
Application-side notification lagThe only true measure of the failureRising 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.ini should be visible in code review, and SHOW CONFIG / SHOW DATABASES output should be diffed after every RELOAD.
  • 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, and SHOW CONFIG over 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 RELOAD is 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.