Your Node API isn't slow. It's waiting for a database connection.
It's fine in testing. Then 200 people open the same page at once and the endpoint takes ten seconds. CPU is flat. Postgres says every query is fast. Nothing looks slow, and yet everything is stuck.
That shape usually means requests are queuing for a database connection, not for compute. This post covers why it happens in Node, how to spot it, the fixes (with code), and how to prove the fix worked.
The pattern
One write-up on dev.to describes a NestJS admin dashboard on Postgres. A stress test fired 200 identical dashboard requests at a 250k-entry ledger through a pool of 5 connections. The first run came back with a p95 of 9.8 seconds. Each load held eight pooled connections: one query for the money cards and six separate statements for queue counts, all recomputed per request even though the 200 requests asked for the same thing.
After the fix (coalescing identical in-flight requests and collapsing six queue-count queries into one), p95 dropped to 213 ms with zero errors, and connections per load fell from eight to three.
The lesson isn't "your query is slow." It's "each request asks the pool for more than the pool can give."
Why it happens
node-postgres (pg) gives you a fixed-size pool. Its documented defaults:
-
max: 10 clients -
connectionTimeoutMillis: 0, which means wait forever - When every client is checked out, new requests "wait in a FIFO queue until a client becomes available"
So the pool doesn't fail when it runs out. It queues, silently, with no timeout. Three common ways to drain it:
1. Unbounded fan-out. await Promise.all(ids.map(id => db.query(...))) doesn't schedule anything. It starts every query at once. With 20 ids in dev that's fine. With thousands in production, one request grabs the whole pool and every other endpoint waits behind it.
2. Holding a connection while doing something else. A transaction that wraps an HTTP call keeps its client for the length of that call. With a pool of 10 and a 5-second partner API, you're capped at 2 requests per second for everything that touches the database.
3. Leaks and nested acquisition. A pool.connect() without a release() on the error path, or a request that holds one client while it waits for a second. With 10 such requests and a pool of 10, nothing moves.
Multiply by instances, too. Ten pods at max: 20 is 200 connections against your database's max_connections, so "just raise max" has a ceiling, and it doesn't fix a leak.
How to see it
The tell is a mismatch: latency climbs, resources don't.
- Per-endpoint p95 rises while CPU stays flat. If the database were the bottleneck, it would be busy. If Node were, CPU would be high. Neither is.
- The pool counters.
pg.PoolexposestotalCount,idleCountandwaitingCount. The signature of starvation isidleCountat 0 andwaitingCountclimbing.
setInterval(() => { const { totalCount, idleCount, waitingCount } = pool; console.log({ totalCount, idleCount, waitingCount }); }, 5000); - A request's time doesn't add up. If the queries inside a request total 80 ms but the request took 4 seconds, the rest went somewhere. Waiting for a client is a prime suspect.
- The same query, many times, in one request. A fan-out shows up as dozens or hundreds of identical statements inside a single request.
On the Postgres side, pg_stat_activity separates the causes: lots of idle in transaction sessions point to lingering transactions, mostly idle sessions while the pool is empty point to leaked clients, and long active sessions or lock waits point to slow queries.
Fixes, with code
Cap concurrency instead of Promise.all over everything
p-limit holds each task until a slot opens:
import pLimit from 'p-limit'; const limit = pLimit(5); // stay well under the pool size const results = await Promise.all( ids.map(id => limit(() => db.query('SELECT * FROM orders WHERE id = $1', [id]))) ); No dependency? A small worker loop does the same job:
async function mapLimit(items, n, fn) { const out = new Array(items.length); let i = 0; const worker = async () => { while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx]); } }; await Promise.all(Array.from({ length: n }, worker)); return out; } Better still, don't run N queries at all. One statement with WHERE id = ANY($1) replaces thousands of round trips:
const { rows } = await db.query( 'SELECT * FROM orders WHERE id = ANY($1::int[])', [ids] ); Fail fast instead of hanging
With the default connectionTimeoutMillis of 0, a starved request waits until the client gives up. Set a timeout so it becomes an error you can see and alert on:
const pool = new Pool({ max: 10, connectionTimeoutMillis: 5000, idleTimeoutMillis: 10000, }); Add a statement_timeout on the Postgres side so a single bad query can't hold a client indefinitely.
Always release, and keep transactions short
Use pool.query() for single statements; it checks out and releases for you. For transactions, use one helper with try/finally, and do slow work (HTTP calls, file uploads) before BEGIN, not inside it.
async function withTransaction(fn) { const client = await pool.connect(); try { await client.query('BEGIN'); const result = await fn(client); await client.query('COMMIT'); return result; } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } } Inside the transaction, pass client down instead of calling pool.query() again. That's how nested acquisition sneaks in.
Size the pool against the whole fleet
Work backwards from your database's connection limit: instances times max has to fit, with headroom for migrations and admin tools. If you have many instances or serverless functions, put a pooler such as PgBouncer in front.
Move bulk work out of the request
A backfill or export that touches thousands of rows belongs in a job queue with its own fixed concurrency, not in an HTTP handler competing with live traffic for the same pool.
Don't compute the same thing 200 times
The dashboard fix above didn't add connections. It made 200 identical concurrent requests share one in-flight promise, keyed by the request's parameters, and dropped it once it settled. Fewer queries per request beats a bigger pool.
Verify the fix
"It feels faster" isn't a result. Compare the endpoint's p95 over an equal window before and after the deploy, and check that its slowest operations changed: fewer calls per request, less total time in the database, and no new timeouts. If p95 dropped but waitingCount still climbs under load, you moved the bottleneck rather than removing it.
Doing this with ApiTraffic
ApiTraffic's APM records the database, cache, queue and outbound work inside each request of a Node app (Express, Fastify and NestJS each have a setup guide) through OpenTelemetry, and attributes each span to the request that started it, even when the query finishes in a pool callback.
- Endpoint details show requests, p50, p95 and p99 per endpoint, and a Time Breakdown of where that endpoint's time goes.
- The request waterfall puts every query on the request's timeline, so a fan-out shows up as a wall of identical
SELECTbars, and you can compare the time in queries against the total. - Top Operations ranks queries by calls per request and flags N+1 patterns, with the code location that issued them.
- Regressions compares p95 before and after a deploy event, so verifying the fix is a table, not a guess.
Setup is the SDK plus a preload so OpenTelemetry starts before pg loads:
node -r @apitraffic/node/register ./server.js Start free and see which endpoint waits the longest: apitraffic.io