---
title: "Database Connection Pools Under Load: Symptoms Teams Misread as \"App Slowness\""
description: "Spot pool exhaustion, waiting threads, and timeout storms—then tune pools and queries with evidence."
publishDate: "2026-09-07"
draft: false
keyword: "connection pool load testing"
intent: "MOFU"
cta: "Use Performate to replay JDBC/ORM-heavy API journeys while tagging DB-dependent routes."
tags: ["k6","load-testing","api-performance","connection","pool"]
---

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 `@Transactional` scopes.
- **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`](https://k6.io/docs/using-k6/metrics/) on DB-heavy endpoints tells a different story—especially tail percentiles ([p95 vs p99](/en/blog/p95-vs-p99-latency)). 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](/en/blog/how-many-virtual-users-k6)) 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:read` and `db:write` let 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_RPS` let CI replay the same script when analytics shifts before a sale event.

```javascript
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-rate` keep read/write percentages honest ([executors reference](https://k6.io/docs/using-k6/scenarios/executors/)).
- **Tags aligned to observability** (`db:read`, `route:order_create`) map k6 slices to APM and DB dashboards ([k6 observability tags](/en/blog/k6-observability-metrics-tags)).
- **Soak segments:** extend duration on write scenarios to catch connection leaks that only appear after 20+ minutes ([soak testing patterns](/en/blog/soak-testing-playbook-memory-leaks)).
- **Correlation IDs** in headers tie k6 iterations to slow-query logs ([correlation IDs](/en/blog/correlation-ids-distributed-tracing-k6)).

**Anti-patterns to avoid**

- Load-testing only `/health` or static assets—zero pool signal.
- Raising pool `maxSize` without measuring wait time—masks query inefficiency until the database itself saturates.
- Using `shared-iterations` with high VU counts on blocking ORM code—misleading concurrency vs arrival-rate results ([scenario types](/en/blog/k6-scenario-types-explained)).

**Pro tip (example command):** the line below surfaces tail latency on tagged routes during pool tuning reviews.

```bash
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](/en/blog/staging-vs-production-load-tests)) |

**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**

1. **Import a Postman collection** (or OpenAPI) that includes `catalog/search` and `orders` POST with realistic bodies. *Problem solved:* one source of truth for payloads that actually trigger joins and writes.
2. **Create two scenarios in the visual editor**—`catalog_reads` at **40 req/s** and `order_writes` at **10 req/s**—matching last week's analytics mix. *Problem solved:* honest read/write pressure without hand-editing executor blocks each sprint.
3. **Apply tags in the scenario panel:** `db:read` on search, `db:write` on order create, plus shared `service:commerce`. *Problem solved:* reports filter the same dimensions as the k6 example above.
4. **Run both scenarios and open the comparison view** in the integrated report. Check whether write `p99` climbs while read stays flat—a classic pool starvation signature. *Problem solved:* backend and DBA debate one export, not conflicting screenshots.
5. **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.
6. **Export the generated k6 script** for CI smoke gates ([CI/CD load testing](/en/blog/load-testing-in-ci-cd)) 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.

[Try Performate free](https://performate.app) | [Book a demo](/demo) | [k6 metrics](https://k6.io/docs/using-k6/metrics/)
