Skip to main content
Sep 7, 2026connection pool load testing

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 @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 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: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.
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).
  • 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 /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).

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

SituationRecommended action
Pool wait timeouts with low DB CPUReduce transaction scope, fix leaks, tune maxLifetime / idle eviction
High DB CPU + rising active sessionsOptimize 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 rateReview lock contention, batch writes, or shard hot tables
Staging passes, prod fails at same RPSCompare 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

  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 editorcatalog_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) 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 | Book a demo | k6 metrics

Ready to optimize your API performance?

Use Performate to replay JDBC/ORM-heavy API journeys while tagging DB-dependent routes.

← Back to all posts