By Lucas Yoris · Performate
Database Connection Pools Under Load: Symptoms Teams Misread as \"App Slowness\"
Spot pool exhaustion, waiting threads, and timeout storms—then tune pools and queries with evidence.
Your API latency graph climbs while CPU stays flat. On-call assumes "we need more app pods"—but Postgres shows 200 sessions waiting, HikariCP logs Connection is not available, request timed out after 30000ms, and p99 only spikes on routes that touch the database. That pattern is pool exhaustion, not mysterious application slowness.
Connection pools under load are a capacity problem hiding inside HTTP metrics. In this guide you will learn why saturated pools look like healthy services in dashboards, how to design k6 scenarios that stress read/write mixes realistically, and which signals should drive pool sizing—not guesswork from default ORM settings.
Why pool saturation masquerades as app slowness
HTTP clients see slow responses. Application servers often report moderate CPU because threads are blocked waiting for connections, not burning cycles on business logic. Under concurrency, several factors compound:
- Fixed pool ceilings cap concurrent DB sessions; extra requests queue at the pool layer while k6 iteration time grows.
- Long transactions hold connections through multiple ORM calls, external API hops, or misplaced
@Transactionalscopes. - Read/write asymmetry lets read-heavy traffic exhaust a shared pool while write paths starve—or vice versa when replicas lag.
- Connection leaks from unclosed resources shrink effective pool size until a restart "fixes" latency temporarily.
- ORM chatty patterns (N+1 queries, eager fetches) multiply round trips per HTTP request, multiplying pool pressure.
Think of a pool like a parking garage with a fixed number of spots. Traffic looks fine at the street level until every spot is taken and new cars circle the block—your API becomes the circling car, not the garage attendant.
When APM looks healthy but k6 iteration time diverges
APM may show acceptable service-level latency if it averages across cached routes. k6 http_req_duration on DB-heavy endpoints tells a different story—especially tail percentiles (p95 vs p99). Pair route tags with database wait metrics (pg_stat_activity, pool JMX, or RDS Performance Insights) so you correlate waiting sessions with specific API families.
Design scenarios that reuse auth tokens but fan out realistic query patterns (VU guidance) instead of hammering one cached GET.
Practical k6 implementation: tag DB-heavy routes and mix read/write load
Model the journeys that actually hold connections—checkout, search-with-joins, admin reports—not only health checks that never touch the pool.
Example script (illustrative—not a production-ready test). The snippet below uses fictional URLs, tokens, and SLO numbers. Adapt base URL, auth, payloads, and thresholds to your environment.
What this example demonstrates:
- Read/write split: two parallel scenarios at 40 and 10 req/s mirror a catalog-heavy production mix instead of 100% writes.
- Route-scoped tags:
db:readanddb:writelet you split latency and failures per pool pressure profile in k6 summaries. - Separate thresholds: write paths may legitimately run slower; aggregate thresholds hide write-side pool starvation.
- Env-driven rates:
READ_RPS/WRITE_RPSlet CI replay the same script when analytics shifts before a sale event.
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE = __ENV.API_BASE || 'https://staging.example.com';
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${__ENV.TOKEN}`,
};
export const options = {
scenarios: {
catalog_reads: {
executor: 'constant-arrival-rate',
rate: Number(__ENV.READ_RPS || 40),
timeUnit: '1s',
duration: '8m',
preAllocatedVUs: 30,
maxVUs: 120,
tags: { db: 'read', route: 'catalog_search' },
exec: 'searchCatalog',
},
order_writes: {
executor: 'constant-arrival-rate',
rate: Number(__ENV.WRITE_RPS || 10),
timeUnit: '1s',
duration: '8m',
preAllocatedVUs: 15,
maxVUs: 60,
tags: { db: 'write', route: 'order_create' },
exec: 'createOrder',
},
},
thresholds: {
'http_req_duration{db:read}': ['p(95)<400', 'p(99)<800'],
'http_req_duration{db:write}': ['p(95)<900', 'p(99)<1500'],
'http_req_failed{db:write}': ['rate<0.02'],
http_req_failed: ['rate<0.01'],
},
};
export function searchCatalog() {
const res = http.get(`${BASE}/api/catalog/search?q=widget&limit=50`, {
headers,
tags: { db: 'read', route: 'catalog_search' },
});
check(res, { 'search 2xx': (r) => r.status >= 200 && r.status < 300 });
sleep(0.2);
}
export function createOrder() {
const body = JSON.stringify({ sku: 'SKU-200', qty: 2, currency: 'USD' });
const res = http.post(`${BASE}/api/orders`, body, {
headers,
tags: { db: 'write', route: 'order_create' },
});
check(res, { 'order 2xx': (r) => r.status >= 200 && r.status < 300 });
sleep(0.5);
}
Patterns that work
- Parallel scenarios with
constant-arrival-ratekeep read/write percentages honest (executors reference). - Tags aligned to observability (
db:read,route:order_create) map k6 slices to APM and DB dashboards (k6 observability tags). - Soak segments: extend duration on write scenarios to catch connection leaks that only appear after 20+ minutes (soak testing patterns).
- Correlation IDs in headers tie k6 iterations to slow-query logs (correlation IDs).
Anti-patterns to avoid
- Load-testing only
/healthor static assets—zero pool signal. - Raising pool
maxSizewithout measuring wait time—masks query inefficiency until the database itself saturates. - Using
shared-iterationswith high VU counts on blocking ORM code—misleading concurrency vs arrival-rate results (scenario types).
Pro tip (example command): the line below surfaces tail latency on tagged routes during pool tuning reviews.
k6 run pool-mix.js --summary-trend-stats="p(95),p(99),max"
What this command demonstrates: k6 prints percentile trends plus max iteration spikes so you can compare read vs write tails when pool wait timeouts cluster just under your configured acquisition limit.
Decision framework: pool tuning vs query optimization vs horizontal scale
| Situation | Recommended action |
|---|---|
| Pool wait timeouts with low DB CPU | Reduce transaction scope, fix leaks, tune maxLifetime / idle eviction |
| High DB CPU + rising active sessions | Optimize queries, add indexes, split read replica traffic |
| Read-heavy spike (sale, launch) | Scale read replicas + separate read pool config; load-test read scenario alone first |
| Write queue backlog under steady arrival rate | Review lock contention, batch writes, or shard hot tables |
| Staging passes, prod fails at same RPS | Compare pool sizes, network latency, and secret-managed DSN limits (staging vs prod) |
Tune the pool first if acquisition wait time dominates and queries are already sub-50ms at low concurrency.
Optimize queries first if pg_stat_statements or APM shows repeated slow SQL regardless of pool size.
Scale horizontally if pool and query baselines are healthy but connection count approaches database max_connections platform limits.
Observability, documentation, and next steps
Pool investigations fail when teams chase the wrong layer. Before the next scale-up request:
- Capture pool metrics (active, idle, pending, timeouts) aligned to the same time window as the k6 run.
- Document read/write scenario rates and their analytics source—not arbitrary 100 VU defaults.
- Alert when
p(99){db:write}diverges from baseline while pool pending count rises above your agreed threshold. - Correlate k6 route tags with slow-query logs and ORM trace spans for the top three latency contributors.
- Archive scenario JSON, pool config snapshots, and git SHA per run so regressions compare apples to apples.
How Performate simplifies DB-heavy load testing
Reproducing ORM-heavy journeys without maintaining fragile script forks is the hard part. Below is a concrete workflow example for the same catalog + order API this article discusses.
Example: replay JDBC-heavy routes with tagged scenarios
- Import a Postman collection (or OpenAPI) that includes
catalog/searchandordersPOST with realistic bodies. Problem solved: one source of truth for payloads that actually trigger joins and writes. - Create two scenarios in the visual editor—
catalog_readsat 40 req/s andorder_writesat 10 req/s—matching last week's analytics mix. Problem solved: honest read/write pressure without hand-editing executor blocks each sprint. - Apply tags in the scenario panel:
db:readon search,db:writeon order create, plus sharedservice:commerce. Problem solved: reports filter the same dimensions as the k6 example above. - Run both scenarios and open the comparison view in the integrated report. Check whether write
p99climbs while read stays flat—a classic pool starvation signature. Problem solved: backend and DBA debate one export, not conflicting screenshots. - Iterate pool settings in staging, re-run with identical scenario weights, and compare reports side by side. Problem solved: pool tuning becomes a controlled experiment, not a production gamble.
- Export the generated k6 script for CI smoke gates (CI/CD load testing) so local tuning and pipeline runs stay aligned.
That workflow maps directly to the cta in this post: replay JDBC/ORM-heavy API journeys while tagging DB-dependent routes in one workspace.
Closing takeaway
Pool exhaustion is a capacity signal disguised as HTTP slowness. Load-test the read/write mixes that actually hold connections, tag every DB-heavy route, and correlate k6 tails with pool wait metrics before you add pods or raise limits blindly.
Run this week's analytics mix against staging—and note whether write paths or read paths carry the latency tail your pool configuration must survive.
Ready to optimize your API performance?
Use Performate to replay JDBC/ORM-heavy API journeys while tagging DB-dependent routes.