Load Testing Connection Pools with JMeter and Gatling

This guide is part of Java Connection Pool Benchmarks. Micro-benchmarks tell you how fast a pool hands out a connection. They tell you nothing about what happens to your service when the pool runs out, which is the failure you actually need to understand before production does it for you.

A load test answers different questions: at what request rate does the pool saturate, how does latency behave past that point, does the acquisition timeout fire before the request deadline, and does the service degrade or collapse. Getting useful answers requires one specific design decision that most load tests get wrong.

Closed-model load generation hides pool saturation

The decision is the load model. A closed model runs a fixed number of virtual users, each sending a request and waiting for the response before sending the next. An open model injects requests at a fixed rate regardless of whether previous ones have finished.

Closed-model tests are self-limiting. When the pool saturates and responses slow down, the virtual users slow down with them, so the arrival rate falls and the queue never grows. You get elevated latency and a comfortable-looking throughput plateau, and you never see the cascade. Real traffic does not behave that way — real users and upstream services keep arriving at the same rate while your service is slow, which is precisely what makes queues explode.

Every meaningful pool load test must use an open model. In Gatling that is constantUsersPerSec or rampUsersPerSec with injectOpen. In JMeter it is the Concurrency Thread Group or the Throughput Shaping Timer driving arrival rate rather than a fixed thread count.

Why the load model changes the result Two latency curves against offered load. The closed-model curve rises and then flattens because slower responses throttle the generator. The open-model curve rises steeply past the saturation point because arrivals continue regardless of response time. A closed-model test cannot reproduce a queue cascade offered request rate p99 closed model — flattens open model — reveals the cliff pool saturates here The number you need is the rate at the dashed line, and only the open model finds it.
Under a closed model the generator throttles itself as the service slows. Only an open model reproduces what production traffic does.

A Gatling scenario for pool saturation

class PoolSaturationSimulation extends Simulation {

  val httpProtocol = http
    .baseUrl("https://orders.staging.internal")
    .acceptHeader("application/json")
    .shareConnections                       // do not confound with HTTP pooling

  // A read that touches the database on every call and is cheap server-side,
  // so the pool — not the query — is the constraint under test.
  val readScenario = scenario("order lookup")
    .exec(
      http("GET /orders/{id}")
        .get("/orders/#{orderId}")
        .check(status.in(200, 503))         // 503 is a valid, expected outcome
    )
    .feed(csv("order-ids.csv").random)

  setUp(
    readScenario.inject(
      // Warm the pool and the JIT before measuring anything.
      constantUsersPerSec(20).during(2.minutes),
      // Then ramp past the expected saturation point.
      rampUsersPerSec(20).to(600).during(8.minutes),
      constantUsersPerSec(600).during(3.minutes)
    )
  ).protocols(httpProtocol)
   .assertions(
     global.responseTime.percentile4.lt(2000),   // p99 under 2 s
     global.failedRequests.percent.lt(1)
   )
}

Four things in that scenario matter more than the numbers.

.check(status.in(200, 503)) treats shed load as a success from the generator’s point of view. If a 503 counts as a failure, Gatling’s failure percentage conflates “the service correctly rejected excess load” with “the service broke”, and the assertion becomes meaningless. Count them separately and assert on each.

The two-minute constant warm-up is not optional on the JVM. A cold pool has no established connections and a cold JIT interprets your data-access code. Measuring the first two minutes gives you numbers about class loading, not about the pool.

.shareConnections prevents the HTTP client’s own connection behaviour from confounding the result. Without it you may be measuring TLS handshakes on the load generator rather than database pool behaviour on the service.

The ramp is long — eight minutes — because pool saturation is a threshold effect and a fast ramp blows past the interesting region in seconds. You want enough samples on each side of the knee to locate it.

The JMeter equivalent

JMeter’s default Thread Group is a closed model and should not be used for this. Use the Concurrency Thread Group from the Custom Thread Groups plugin, paired with the Throughput Shaping Timer to drive arrival rate:

Concurrency Thread Group
  Target Concurrency: 800          # a ceiling, not the load level
  Ramp Up Time: 8 min
  Hold Target Rate Time: 3 min

Throughput Shaping Timer
  start_rps  end_rps  duration
     20        20       120       # warm-up
     20       600       480       # ramp
    600       600       180       # hold

The Concurrency Thread Group’s target concurrency here is a ceiling that must be high enough not to bind — if it binds, you are back to a closed model. Set it well above the arrival rate multiplied by your worst expected response time, then let the Throughput Shaping Timer determine the actual rate.

Add a Backend Listener writing to InfluxDB or Prometheus rather than reading the summary at the end. The interesting behaviour is a transition, and a single aggregate row across the whole run averages it away.

What to instrument on the service side

The load generator tells you what the client saw. Pool diagnosis needs the server side of the same window, and four series are enough:

Metric What it proves
hikaricp_connections_pending Callers are queuing — the unambiguous saturation signal
hikaricp_connections_acquire_seconds (p99) How long the queue actually costs a request
hikaricp_connections_usage_seconds (p99) How long each borrow holds a connection
Database active backend count Whether the database or the pool is the constraint

The relationship between them is the whole analysis. Pending waiters rising while borrow-hold time stays flat means the pool is too small for the arrival rate — raising maximumPoolSize will help. Pending waiters rising because hold time rose means the database slowed down, and a larger pool sends more concurrent work at an already-struggling database. Same client-side symptom, opposite correct action.

Capture the server-side view too. If active backends are pinned at the database’s useful concurrency with wait events showing lock or buffer contention, the pool is already as large as it should be — that reading is described in interpreting pg_stat_activity wait events for pool issues.

Two causes of a growing queue Both branches start from pending waiters rising. If borrow hold time is flat, the pool is undersized and should grow. If hold time also rose, the database slowed and enlarging the pool would add concurrent load to a struggling database. pending waiters rising in the test did borrow hold time rise too? no yes pool is undersized raise maximumPoolSize if the database has headroom database is the constraint a bigger pool makes it worse tune queries or add capacity The client-side symptom is identical on both branches, which is why hold time must be recorded.
Queue depth alone is ambiguous. Pairing it with borrow hold time determines which fix is correct.

Designing the test environment

A load test that runs against a database three times smaller than production produces a saturation point three times lower and a conclusion you cannot use. Three environment properties must match production or the result is decorative:

Database instance class and storage. Useful concurrency comes from CPU count and I/O parallelism. A db.t3.medium staging instance will saturate at a completely different pool size than a db.r6g.4xlarge.

Data volume and distribution. A query against 10,000 rows uses a different plan and holds a connection for a different length of time than the same query against 100 million. Restore a production-shaped snapshot, or generate data with realistic cardinality.

Network path. If production goes through a proxy or a load balancer and staging connects directly, you are testing a different system — the extra hop changes latency, and the proxy imposes its own connection ceiling.

Replica count matters too, and in a specific way: total connections against the database are replicas x maximumPoolSize, so testing one instance and multiplying is only valid up to the point where the database’s own ceiling binds. Test at production replica count if you want to observe that ceiling. Deriving the per-replica share is covered in Connection Pool Sizing Formulas.

Which environment differences invalidate the result Instance class, data volume and network path all change where the saturation knee falls. A staging environment that differs on any of them produces a number that cannot be transferred to production. Three differences that move the saturation knee instance class useful concurrency comes from CPU and from I/O parallelism a smaller instance saturates earlier match the class, not just the engine data volume plans change with cardinality hold time changes with plan 10k rows is a different query restore a production-shaped snapshot network path a proxy adds a ceiling of its own a load balancer adds latency direct staging is a different system reproduce the full path Treat the measured knee as a relative number for comparing configurations, and validate it against production once deployed. Replica count matters too: the database ceiling only binds at the fleet size you actually run.
Instance class, data volume and network path each move the knee. Differ on any and the number does not transfer.

Common failure patterns and remediation

Symptom in the test Underlying cause Remediation
Latency plateaus, never spikes Closed-model load generation Switch to open-model injection
Throughput caps well below expectation Load generator saturated, not the service Check generator CPU and socket limits; distribute the generator
First minute dominates the percentiles No warm-up; JIT and cold pool measured Discard or exclude a warm-up phase
Pool never saturates at any rate Query is served from cache and never borrows Use an endpoint that reliably touches the database
Every request fails past the knee Acquisition timeout longer than the request deadline Set the pool timeout below the deadline so failures are clean
Results differ run to run by 2× Shared staging database with other traffic Isolate the database, or record concurrent load
Test passes, production still collapses Staging database far smaller than production Match instance class, data volume, and network path
503s counted as failures, assertions meaningless Shed load treated identically to errors Separate expected shedding from genuine errors in checks

Reading the result

The output you want from the run is three numbers, not a pass or a fail:

  1. The saturation rate — the arrival rate at which pending waiters first become persistently non-zero. This is your service’s real capacity per replica.
  2. The degradation shape past it — does p99 rise smoothly, or does the service fall over? A smooth rise with clean 503s means your timeout and breaker configuration is working; a vertical cliff with connection resets means it is not.
  3. The recovery time — after the load drops back below the saturation rate, how long until p99 returns to baseline. A pool that takes minutes to recover from a 30-second overload has a queue that grew unbounded, which argues for a circuit breaker as described in implementing circuit breakers for connection acquisition failures.

Run the whole thing again after each configuration change, one change at a time. A test that changes pool size and timeout together tells you the combination is better or worse and nothing about which change did it.

Operational boundary

A load test measures a synthetic workload against a synthetic environment. It is excellent at finding the saturation knee and the degradation shape, and poor at predicting absolute production numbers, because real traffic has a query mix, a cache hit rate, and a diurnal shape you did not reproduce. Treat the knee as a relative measure — useful for comparing configurations — and validate the absolute number against production metrics once deployed.

It also cannot find a slow leak. A connection leaked once per thousand requests exhausts a pool over hours, and a twelve-minute test will not show it. Leak detection is a separate mechanism entirely, covered in configuring the HikariCP leak detection threshold.

Frequently Asked Questions

Why does the load model matter so much?
Because a closed model reduces its own arrival rate when the service slows down, which is exactly the feedback loop that pool saturation lacks in production. Only an open model produces a growing queue.
Should I test against production data volumes?
Yes, or as close as you can get. Query plans and execution times change with data volume, and borrow hold time is directly determined by execution time.
How long should a run be?
Long enough to include a warm-up phase you exclude, a ramp slow enough to resolve the knee, and a hold phase at the top. Twelve to fifteen minutes is a reasonable total for a single configuration.
Do 503 responses count as failures?
Count them separately. Shed load under overload is correct behaviour and evidence the protection works; conflating it with errors makes your assertions uninterpretable.
Can I load test the pool without the application?
You can, with a JMH harness that borrows and returns connections directly — that is what the micro-benchmark methodology in the parent guide is for. It measures pool mechanics well and service behaviour not at all.
What if the load generator is the bottleneck?
Watch generator CPU, ephemeral port exhaustion, and file descriptor limits. If the generator is above about 70% CPU, distribute it across hosts before trusting the numbers.
Should I test with the circuit breaker enabled?
Run both. With it disabled you find the true saturation knee; with it enabled you verify the breaker opens at the right point and the service degrades rather than collapsing.