Interpreting pg_stat_activity Wait Events for Pool Issues

This guide is part of PostgreSQL Server-Side Connection Diagnostics. Client-side pool metrics tell you that connections are all checked out. They cannot tell you why the connections are not coming back. Wait events answer that: for every backend, PostgreSQL publishes what it is currently blocked on, and a handful of those events point straight back at pool configuration.

The critical distinction to hold onto is that a backend can be busy in three completely different ways, and only one of them means the database is the bottleneck:

  • Doing work. wait_event_type IS NULL — the backend is on CPU or in a syscall that PostgreSQL does not instrument. This is real database work.
  • Waiting on something inside PostgreSQL. Lock, LWLock, IO, BufferPin — genuine database contention.
  • Waiting on your application. Client: ClientRead — the backend has nothing to do and is waiting for the client to send the next command.

That last one is the pool diagnosis hiding in plain sight. A hundred backends in state = 'idle in transaction' with wait_event = 'ClientRead' is not a database problem at all. It is an application holding transactions open across network calls, and no amount of database tuning will help.

The query to start from

SELECT state,
       wait_event_type,
       wait_event,
       count(*)                                   AS backends,
       max(now() - state_change)::interval(0)     AS longest_in_state,
       max(now() - xact_start)::interval(0)       AS longest_xact
FROM pg_stat_activity
WHERE backend_type = 'client backend'
  AND pid <> pg_backend_pid()
GROUP BY 1, 2, 3
ORDER BY backends DESC;

Grouping before you look at individual rows is deliberate. A single long-running query is a query problem; two hundred backends sharing one wait event is a systemic problem, and only the grouped view makes the difference obvious. backend_type = 'client backend' filters out autovacuum workers, the walwriter and background workers, which otherwise pad the counts with events that have nothing to do with your pool.

Which wait events point where Three columns. The left column lists events meaning the backend is doing real work. The middle lists events meaning contention inside PostgreSQL. The right lists client wait events, which mean the application is the one holding things up. The wait event class tells you which team owns the problem doing real work wait_event_type IS NULL state = active the pool is correctly sized if this count matches your CPU parallelism fix: query tuning or more hardware contention inside PostgreSQL Lock: transactionid, tuple LWLock, IO, BufferPin a bigger pool makes this worse, not better — more contenders fix: shorten transactions, add indexes waiting on the application Client: ClientRead state = idle in transaction the database is idle while your pool reports full utilisation fix: application code, not the database Client-side pool metrics look identical in all three cases: connections checked out, callers queuing. Only the server-side wait event distinguishes them, and each one calls for a different fix.
Three wait classes, three owners. A saturated pool looks the same from the client in every case.

The events that matter for pooling

Event State usually seen with What it means Pool implication
Client: ClientRead idle in transaction Backend awaiting the next command inside an open transaction Application is holding a connection across non-database work
Client: ClientRead idle Normal pooled connection at rest None — this is what an idle pool looks like
Lock: transactionid active Waiting for another transaction to commit or roll back Raising pool size adds contenders, not throughput
Lock: tuple active Row-level contention on a hot row Same — serialisation point is the row, not the pool
LWLock: BufferMapping active Shared buffer contention, often from too many concurrent backends Pool is too large for the instance
LWLock: WALWrite active Commit throughput bound by WAL fsync More connections increase queueing at the same fsync
IO: DataFileRead active Reading pages from disk Working set exceeds shared buffers
IPC: BgWorkerShutdown active Parallel query workers finishing Each backend may consume several worker slots
Timeout: PgSleep active An explicit sleep in the query or a lock retry loop Almost always application logic holding a connection

The pattern to internalise is that only the first row calls for an application-side fix to how connections are borrowed, while several of the others actively argue against enlarging the pool. LWLock: BufferMapping and LWLock: WALWrite dominating the distribution means the instance is already past its useful concurrency; adding connections increases queueing at the same serialisation point and raises tail latency without raising throughput. That relationship is the empirical basis for the sizing formulas in Connection Pool Sizing Formulas.

Finding the transactions that hold connections open

The single highest-value query for pool problems isolates backends that are inside a transaction but not executing anything:

SELECT pid,
       application_name,
       client_addr,
       now() - xact_start      AS xact_age,
       now() - state_change    AS idle_for,
       wait_event_type,
       wait_event,
       left(query, 120)        AS last_statement
FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND now() - state_change > interval '5 seconds'
ORDER BY xact_age DESC;

The query column here is the last statement executed, not a running one — that is exactly what you want, because it names the statement after which the application went away to do something else. Common culprits it reveals: a transaction opened before an HTTP call to a payment provider, an ORM session left open across a template render, a batch job that begins a transaction and then reads a file.

Each such backend is a pooled connection that is unavailable to anyone else, and it is also holding its snapshot, which prevents vacuum from cleaning up dead tuples. Two problems for the price of one.

Set idle_in_transaction_session_timeout so these cannot accumulate indefinitely:

ALTER DATABASE orders SET idle_in_transaction_session_timeout = '30s';

That converts a silent connection leak into a visible, attributable error — FATAL: terminating connection due to idle-in-transaction timeout — with the application name and address in the log. It is one of the highest-value single settings on a pooled PostgreSQL instance.

Correlating with the client-side pool

Wait events are only half the picture. The diagnosis comes from putting the server-side distribution next to the client-side pool state at the same moment:

Client pool state Server-side picture Diagnosis
Pool exhausted, callers queuing Most backends active, wait_event_type NULL Genuine database saturation — the pool is doing its job
Pool exhausted, callers queuing Most backends idle in transaction, ClientRead Application holding transactions open; fix the code
Pool exhausted, callers queuing Most backends idle, ClientRead Connections leaked — borrowed but never used or returned
Pool exhausted, callers queuing Backends far fewer than pool size Connections are stuck client-side; check for a severed network path
Pool has headroom, requests slow A few backends active for a long time Individual slow queries, not a pool problem
Pool exhausted, callers queuing Heavy Lock: transactionid Row contention; a larger pool will make latency worse

The third row deserves emphasis because it is the classic leak signature: the pool believes every connection is checked out, but the server shows those same connections sitting idle with no transaction open. That combination means application code borrowed connections and never returned them. On the JVM the borrow site is recoverable directly — see configuring the HikariCP leak detection threshold.

The fourth row is the network case: fewer server backends than the pool believes it holds means connections died without the pool noticing, which is what connection age caps exist to prevent.

Two views, one diagnosis A client-side pool showing all connections checked out and callers queuing, paired with three possible server-side distributions. Each pairing leads to a different conclusion: database saturation, held transactions, or leaked connections. The client view is identical in all three cases client pool active = max idle = 0 pending > 0 this is where every investigation starts server: backends active, no wait event the pool is correctly sized and the database is the constraint server: idle in transaction, ClientRead the application holds transactions across non-database work server: idle, no transaction open connections were borrowed and never returned — a leak Capture both views within the same few seconds. Sampled minutes apart they can describe different incidents.
One client symptom, three server-side explanations, three different fixes. Always sample both sides together.

Common failure patterns and remediation

Symptom Wait event picture Remediation
Pool exhausted, database CPU near zero ClientRead with idle in transaction Move non-database work outside the transaction; set idle_in_transaction_session_timeout
Latency rises with every pool size increase LWLock: BufferMapping or WALWrite growing Reduce pool size toward the instance’s useful concurrency
A few backends never finish Lock: transactionid on one blocker Find the blocker with pg_blocking_pids(); shorten the holding transaction
Sudden spike in IO: DataFileRead Working set exceeded shared buffers after a data growth Index or cache work, not pool work
Connections exist server-side but the pool says none are free Backends idle, no transaction Application leak; enable borrow-site leak detection
Pool count exceeds server backend count Sockets severed upstream Set a connection age cap below the shortest external idle timer
Wait events all Client: ClientWrite Client is not reading results fast enough Reduce result set size or fix client-side backpressure

Sampling rather than snapshotting

pg_stat_activity is a snapshot. A single query catches whatever was happening in that instant, which is close to useless for intermittent problems. Sample it on an interval and aggregate:

CREATE TABLE IF NOT EXISTS wait_samples (
  sampled_at      timestamptz NOT NULL DEFAULT now(),
  state           text,
  wait_event_type text,
  wait_event      text,
  backends        int
);

INSERT INTO wait_samples (state, wait_event_type, wait_event, backends)
SELECT state, wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY 1, 2, 3;

Run that every few seconds from a scheduler and you have a poor man’s wait-event profiler. Aggregating over a window then tells you what the instance was mostly doing during an incident rather than what it happened to be doing at one moment:

SELECT wait_event_type, wait_event, sum(backends) AS backend_seconds
FROM wait_samples
WHERE sampled_at BETWEEN '2026-07-24 09:00' AND '2026-07-24 09:30'
GROUP BY 1, 2
ORDER BY backend_seconds DESC
LIMIT 15;

If the pg_wait_sampling extension is available on your instance, use it instead — it does the same sampling in shared memory at far lower cost and with much higher resolution. On managed databases where extensions are restricted, the table-based approach above is the practical fallback, and Performance Insights on RDS and Aurora exposes the same data through its own interface.

A snapshot is not a profile A single query of pg_stat_activity captures one instant and can miss an intermittent condition entirely. Repeated sampling aggregated over a window shows what the instance was mostly doing. One query answers a different question than repeated sampling single snapshot one instant, whatever was true then misses anything intermittent cheap but not reproducible fine for an active incident repeated sampling aggregated over a window shows the dominant wait, not a moment survives the incident for review the basis of a wait profile During an incident, take the snapshot first — it is immediate. Then start sampling so the window is on record. Where pg_wait_sampling is available it does the same job in shared memory at far lower cost.
Snapshot for the moment, sampling for the window. Diagnosis of an intermittent problem needs the second.

Operational boundary

Wait events describe what backends are blocked on right now. They do not attribute a wait to a query plan, they do not survive a connection closing, and they cannot see time spent in your application between statements — only that the backend was waiting for you. For anything that needs statement-level attribution, pg_stat_statements is the right tool and answers a different question.

They also say nothing about connections that never reached PostgreSQL. If your pool cannot get a connection because the proxy in front of the database is out of server slots, pg_stat_activity will look perfectly healthy. Check the proxy’s own statistics first in that topology, as described in PgBouncer Metrics Monitoring.

Frequently Asked Questions

Is Client: ClientRead a problem?
Only in combination with state = 'idle in transaction'. With state = 'idle' it is simply what a pooled connection at rest looks like, and seeing many of them is normal and healthy.
Why does wait_event_type show NULL for busy backends?
NULL means the backend is not waiting on anything PostgreSQL instruments — it is on CPU or in an uninstrumented syscall. For an active backend that is the signature of real work being done.
How do I find what is blocking a lock wait?
SELECT pg_blocking_pids(pid) returns the array of backend PIDs holding the lock the given backend wants. Join that back to pg_stat_activity to see what the blocker is doing.
Does a bigger pool ever fix a lock wait?
No. Lock waits are serialisation on a resource, so adding concurrent contenders lengthens the queue. The fix is shorter transactions, better indexes, or a different access pattern.
Can I see wait events through a connection proxy?
Yes, but the client_addr column will show the proxy rather than the application, and in transaction pooling a given backend serves many applications over time. Set a distinct application_name per service so the column stays useful.
What is the cheapest thing to change first?
idle_in_transaction_session_timeout. It costs nothing, turns a silent connection leak into a logged error naming the responsible application, and frees the snapshot that was blocking vacuum.
Should I alert on wait events directly?
Alert on the composite signal — pool queue depth rising while active backend count stays flat — rather than on any single event. That combination is specific to pool problems, whereas individual wait events fluctuate constantly under normal load.