# Is Your Rust Request Waiting for a Connection or for PostgreSQL?

By Mehdi Akiki
Published: 2026-10-30T10:02:25Z
Updated: 2026-10-31T19:37:31Z
Canonical: https://www.mehdi.cz/blog/rust-request-waiting-for-pool-or-postgresql
Reviewed source: https://github.com/mehdiakiki/mehdi-cz/blob/a4b48c12b72f1a9c1924b14d3b2a672bbaf5eb0b/data/blog/rust-request-waiting-for-pool-or-postgresql.mdx

A request takes twelve seconds. Its query normally takes a few milliseconds. There is no slow-query entry for most of the wait.

Before changing the pool size, find out when the request obtained a connection. A timer around `load_account` might include a queue, connection acquisition, SQL, and work after the result arrived. The function name does not tell you where those twelve seconds went.

We can separate three cases: a task that has not asked for a connection yet, a task waiting for a connection someone else holds, and a query waiting for a PostgreSQL lock. Then we can check a common repair: returning the connection before a slow HTTP call.

## Start the trace before acquire

Record a request ID and a stage just before `pool.acquire()`. Record when acquisition returns, including failures. Time the query separately. Keep response encoding and writing in their own spans. Use a monotonic clock for durations.

This gives you a first place to look:

| Last known stage                   | Where to investigate                               |
| ---------------------------------- | -------------------------------------------------- |
| Acquisition has not started        | Application gates, task polling, runtime progress  |
| Acquisition started but is pending | Pool borrowers, connection creation, health checks |
| Query started                      | That database session and its waits                |
| Query finished                     | Decoding, queues, encoding, response consumption   |

In the first [PostgreSQL reproduction](https://github.com/mehdiakiki/mehdi-cz/blob/6c1abf15084ad31d0a672a97fae83e9fb6e9735a/experiments/sql-rust/postgres-sqlite/src/bin/pg_waits.rs), a channel parks a task before acquisition. The observer checks that the pool still has a connection. Releasing the channel lets the query run.

The database path is healthy. The task is waiting elsewhere. In a service, that could be a semaphore, mutex, another service, or a future its caller has stopped polling. The [async Rust investigation](/blog/how-async-rust-works-under-the-hood) explains why a pending future alone does not name the resource it needs.

Keep request identity across task boundaries. You do not need private query parameters or customer documents in ordinary logs to make these stages useful.

## An idle backend may still be checked out

The second case uses a one-connection pool. One task acquires the connection, reads its backend PID, and holds the handle. Another task tries to acquire.

A separate observer finds the first backend in `pg_stat_activity`. Its state is `idle`. PostgreSQL is waiting for the next command, but SQLx cannot give that checked-out connection to another borrower.

This is the ownership detail I would inspect first when queries are short and pool waits are long. A handler can hold a connection while waiting for HTTP, serializing data, or waiting on a channel. A returned stream can keep borrowing it after a helper function has returned.

The [SQLx pool documentation](https://docs.rs/sqlx/0.8.6/sqlx/struct.Pool.html) describes checkout and release. Look at the whole lifetime of the handle, not only the SQL duration. In this reproduction, dropping the handle lets the next task acquire and run another query.

The error `pool timed out while waiting for an open connection` does not prove that another request held every slot. The [acquisition timeout](https://docs.rs/sqlx/0.8.6/sqlx/pool/struct.PoolOptions.html#method.acquire_timeout) can also cover establishing or checking a connection. DNS, TLS, authentication, and pool hooks may consume that time.

## A lock wait belongs to a database session

The third case begins a transaction and updates a row. Before it commits, a second connection tries to update the same row.

The second request has passed acquisition. Capture its backend PID and ask what it is waiting for:

```rust
async fn blockers(pool: &PgPool, pid: i32) -> Result<(Option<String>, Vec<i32>)> {
    let observation = sqlx::query_as(
        "SELECT wait_event_type, pg_blocking_pids(pid)
         FROM pg_stat_activity WHERE pid = $1",
    )
    .bind(pid)
    .fetch_one(pool)
    .await?;
    Ok(observation)
}
```

The test waits until the category is `Lock` and the blocker list contains the exact PID of the first connection. Only then does it roll back the holder. The waiting update finishes, and a separate read sees one increment.

That ordering is useful. Sleeping for a while and then releasing the first transaction would not show that the second query had reached the lock.

PostgreSQL documents [activity and wait events](https://www.postgresql.org/docs/18/monitoring-stats.html#PG-STAT-ACTIVITY-VIEW) and [blocking-session functions](https://www.postgresql.org/docs/18/functions-info.html#FUNCTIONS-INFO-SESSION-TABLE). Read the wait category before interpreting the event name. An active backend need not be using CPU; one doing CPU work may have no wait event.

Use an approved monitoring role to inspect other sessions. Do not put administrator credentials in the application just for this query. PIDs can also change after reconnects, so capture the identity for the connection you are investigating.

## Return the connection before unrelated waiting

Consider an endpoint that reads an address, asks a delivery service for a quote, then saves it. The HTTP call does not use PostgreSQL. Keeping the checkout through that call ties up database capacity while the database has nothing to do.

Read the address into owned data, end the checkout scope, and call the delivery service. Acquire again when there is database work to do. Passing a pool directly to a SQLx query can also scope the checkout to that operation.

The [HTTP reproduction](https://github.com/mehdiakiki/mehdi-cz/blob/6c1abf15084ad31d0a672a97fae83e9fb6e9735a/experiments/rust-backend/tests/practical_connection_scope.rs) uses a real loopback exchange and a one-slot pool. It pauses the HTTP response after the server reports that the request arrived.

When the handler retains its connection, an independent borrower cannot acquire. With the shorter scope, that borrower completes a query returning seven while HTTP is still paused. After the handler finishes, another query returns eight.

An [additional test](https://github.com/mehdiakiki/mehdi-cz/blob/6c1abf15084ad31d0a672a97fae83e9fb6e9735a/experiments/rust-backend/tests/pool_lifetime.rs) uses a gated future for the quote. It checks the same progress question and cancels a task while it owns the checkout. The test waits for cancellation to finish, then acquires again. That checks resource recovery, rather than assuming that sending a cancellation request has completed it.

The short timeouts bound these tests. They are not recommended production settings or latency measurements. They also do not prove that cancelling a Rust task stops database work already sent to PostgreSQL. The [database timeout article](/blog/rust-timeout-does-not-stop-postgresql-query) investigates that separate problem.

## What if the address changes during the quote?

Shorter connection lifetimes solve the capacity problem. They do not decide whether the later write is still valid.

Suppose the quote used address revision 12. Another request saves revision 13 before the quote returns. Saving the old price unconditionally would attach it to an address it was never calculated for.

The example checks the revision when saving:

```rust
let changed = sqlx::query(
    "UPDATE pool_customers SET quote_cents = $1 \
     WHERE id = $2 AND revision = $3",
)
.bind(quote_cents)
.bind(customer_id)
.bind(expected_revision)
.execute(pool)
.await?
.rows_affected();
```

One affected row means the customer still matched the input revision. Zero means the row disappeared or changed. Handle that result: obtain a new quote or tell the caller the input changed. Ignoring it can report success even though nothing was saved.

Every change to a quote input must advance the revision. Decide how two quotes for the same revision compete, and whether saving a quote should advance it too. SQL cannot choose those product rules.

Here the version check and quote update protect the same customer row. If the quote is saved in another table, a conditional lookup inside an insert can still race with an address update. Lock the input row through the save, or choose another transaction protocol that makes concurrent writers conflict. The tests above cover the same-row update; they do not prove a separate-table protocol.

Keeping the original connection would not protect the address by itself. A transaction, locks, and isolation are separate choices. Keep operations together when they must commit together; the [PostgreSQL transaction guide](/blog/sql-postgresql-rust-service-transactions) explains that boundary.

In the [recorded run](https://github.com/mehdiakiki/mehdi-cz/actions/runs/41209727237/job/124561980630), all three added pool tests passed. The held checkout produced `PoolTimedOut`. The shorter scope allowed `SELECT 1` before the quote was released. Cancellation returned the connection. A stale quote affected zero rows; a quote for the current revision affected one and was stored.

## If requests still queue

Now compare arrival rate, acquisition waits, checkout lifetimes, and database capacity. More connections may help if PostgreSQL can do more useful work. They can also move the queue into the server and increase contention. Count all application replicas, workers, and rolling deployment overlap against the connection budget.

For a lock wait, inspect the holder before stopping it. It may be waiting on an external call with a transaction still open. For a checkout that survives the first result row, follow the [slow consumer investigation](/blog/slow-postgresql-row-consumer-holds-rust-connection).

If a model calls the operation, it also needs a clear identity and work budget. The [read-only PostgreSQL MCP example](/blog/read-only-postgresql-mcp-tool-limits) checks those boundaries on a reused connection.

A useful fix leaves you able to answer the next incident more precisely: where did this request wait, who owned the resource, and did releasing it preserve the intended write?
