How to Load Test a Spring Boot App

To load test a Spring Boot app, start from its real limits. Tomcat runs up to 200 request threads (server.tomcat.threads.max), and HikariCP gives those threads only 10 database connections by default. Size the pool from your database, set the JVM heap with -XX:MaxRAMPercentage so the container's memory is actually used, ramp up long enough for the JIT to warm, and watch Actuator's hikaricp.connections.pending, tomcat.threads.busy and jvm.gc.pause. On Java 21 or later, also test with spring.threads.virtual.enabled=true, which moves the bottleneck from threads to connections.
How Spring Boot handles concurrent requests
A Spring MVC app on embedded Tomcat uses thread-per-request. Tomcat's NIO connector accepts connections and hands each request to a worker thread, which runs your controller, service and repository code to completion, including blocking JDBC calls and HTTP client calls. The current Spring Boot reference (4.1) lists these defaults:
server.tomcat.threads.max = 200: maximum worker threads, so at most 200 requests execute at once.server.tomcat.threads.min-spare = 10server.tomcat.max-connections = 8192: connections Tomcat will accept and hold open.server.tomcat.accept-count = 100: OS backlog once max-connections is reached.server.shutdown = graceful
So when more than 200 requests are in flight, the extra ones wait on open connections inside Tomcat without a thread. Clients see higher latency, not errors, until timeouts fire. WebFlux apps are different: a small number of event-loop threads handle everything, and a single blocking call on them stalls many requests. That case is closer to the Node.js event loop problem than to this page.
What usually breaks first
HikariCP runs out of connections
HikariCP's defaults are maximumPoolSize = 10 and connectionTimeout = 30000 ms. By Little's Law, if each request holds a connection for 40 ms, 10 connections support about 250 requests per second through the database. Past that, threads queue for a connection and eventually fail with:
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms (total=10, active=10, idle=0, waiting=187)
The waiting number is how many threads are queued. Before that error appears, p95 rises by however long threads wait. The common cause is @Transactional on a method that also calls a remote API, so a connection is held during an HTTP call that takes 300 ms. See database connection pool exhausted for the full diagnosis.
Thread pool saturation
When downstream calls are slow, all 200 Tomcat threads end up blocked waiting on them, and latency for every endpoint (including health checks) rises together. If your Kubernetes liveness probe hits the same connector, pods get restarted in the middle of the test, which makes things worse.
GC pauses and heap limits
The JVM is container-aware. Without -Xmx, the maximum heap is 25% of the container memory limit (MaxRAMPercentage=25). A 2 GB pod gets 512 MB of heap, high allocation rates under load trigger frequent collections, and jvm.gc.pause shows up directly in your p99. If the heap is too large for the pod instead, the kernel kills the container (OOMKilled, exit code 137) with no Java stack trace.
Settings to check before testing
application.yml
server:
tomcat:
threads:
max: 200
min-spare: 20
accept-count: 100
max-connections: 8192
mbeanregistry:
enabled: true # needed for tomcat.threads.* metrics
spring:
datasource:
hikari:
maximum-pool-size: 30
minimum-idle: 30 # fixed-size pool, as HikariCP recommends
connection-timeout: 2000
max-lifetime: 1800000
threads:
virtual:
enabled: false # run once with true on Java 21+
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
metrics:
distribution:
percentiles-histogram:
http.server.requests: true
A short connection-timeout (2 s instead of 30 s) is deliberate. Under overload you would rather fail fast with a clear error than hold 200 threads for 30 seconds each.
Sizing the pool
Bigger is not better. The database executes queries on its CPU cores and disks, and 200 connections on an 8-core database mostly add context switching and lock contention. Start from the database side: total connections across all instances must fit under its max_connections, with room for migrations and admin tools. Six pods × 30 connections = 180, which needs a PostgreSQL limit well above the default 100. The connection pool size calculator does this math.
JVM flags
JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=70 -XX:+UseG1GC \
-Xlog:gc*:file=/tmp/gc.log:time,uptime \
-XX:+HeapDumpOnOutOfMemoryError"
G1 is the default collector on server-class machines. ZGC (-XX:+UseZGC) is worth a comparison run if p99 is dominated by GC pauses and you can spare some CPU and memory.
Virtual threads: what changes under load
With spring.threads.virtual.enabled=true on Java 21+, Tomcat runs each request on a virtual thread. Blocking I/O parks the virtual thread instead of holding an OS thread, so the 200-thread cap stops being a limit for I/O-bound endpoints.
That does not create capacity downstream. Thousands of concurrent requests will now reach HikariCP at once. If the pool has 30 connections, 970 virtual threads wait on it, and you get the same timeout error with a much larger waiting count. Virtual threads make the connection pool, rate limits on downstream services and memory per request your real limits.
Pinning matters too. On Java 21, a virtual thread that blocks inside a synchronized block pins its carrier thread. JDK 24 (JEP 491) removed most of those cases, so test on Java 25 LTS if you can. Run the same scenario with virtual threads on and off and compare p95, error rate and pool wait time.
Spring Boot tuning table
| Setting | Default | What to change | Why |
|---|---|---|---|
server.tomcat.threads.max | 200 | Usually keep; raise only if threads wait on I/O and the DB has headroom | More threads than connections just moves the queue |
server.tomcat.accept-count | 100 | Keep, or lower to shed load sooner | Backlog only matters after 8,192 connections |
server.tomcat.mbeanregistry.enabled | false | true | Exposes tomcat.threads.busy to Micrometer |
spring.datasource.hikari.maximum-pool-size | 10 | From DB capacity ÷ instances | 10 is the most common Spring Boot bottleneck |
spring.datasource.hikari.connection-timeout | 30000 ms | 1000 to 3000 ms | Fail fast instead of tying up threads |
spring.threads.virtual.enabled | false | Test true on Java 21+ (25 preferred) | Removes thread cap for blocking I/O |
| JVM max heap | 25% of container memory | -XX:MaxRAMPercentage=60-75 | Less GC pressure; leave room for metaspace and thread stacks |
Histogram for http.server.requests | off | on | Lets Prometheus compute p95 and p99 per URI |
What to put in the test scenario
- Endpoints by real traffic share, including the slow ones: reports, searches, anything with pagination over large tables.
- Endpoints that call other services. In microservices, these are where thread pools fill up. Decide whether downstream services are real, stubbed, or part of the test.
- Authentication as clients really use it. For stateless JWT APIs, get tokens once and reuse them. For session apps, keep the
JSESSIONIDcookie per virtual user. - CSRF for session-based apps. Spring Security enables CSRF protection by default for browser apps. Read the token from the page (the
_csrfparameter) or, withCookieCsrfTokenRepository, from theXSRF-TOKENcookie, and send it back asX-XSRF-TOKEN. Missing tokens show up as 403s that look like a capacity problem but are a script problem. - A ramp-up of 2 to 5 minutes so JIT compilation, pool fill and caches settle. See ramp-up period.
Actuator and Micrometer signals to watch
| Metric | Healthy | Trouble |
|---|---|---|
hikaricp.connections.pending | 0 most of the time | Sustained above 0: pool is the bottleneck |
hikaricp.connections.timeout | 0 | Any increase: requests failed waiting for a connection |
hikaricp.connections.usage | Short | Long: transactions hold connections during other work |
tomcat.threads.busy vs tomcat.threads.config.max | Well under max | At max: thread saturation, latency queues inside Tomcat |
jvm.gc.pause | Short, infrequent | Pauses lining up with p99 spikes |
jvm.memory.used after GC | Flat over time | Rising floor: leak; confirm with a soak test |
http.server.requests by uri and status | Stable p95 | One URI's p95 rising first identifies the culprit |
Compare server-side http.server.requests latency with the client-side latency from the load test. If the client sees 900 ms and Spring sees 100 ms, requests are waiting before they reach a thread (Tomcat queue, ingress, load balancer). If both see 900 ms, the time is inside the app. For tail behavior, tail latency explains why p99 reacts to GC before p50 does.
Worked example: finding the real ceiling
An illustrative service runs 4 pods, each with the default 200 Tomcat threads and a HikariCP pool raised to 20. The main endpoint spends 15 ms of CPU and holds a database connection for 25 ms, with 60 ms of total server time.
- Thread ceiling: 200 threads ÷ 0.06 s = about 3,300 RPS per pod. Not the limit.
- Connection ceiling: 20 connections ÷ 0.025 s = 800 RPS per pod, or 3,200 RPS across 4 pods, if the database keeps those 25 ms flat.
- CPU ceiling: with 2 vCPUs per pod, 2 ÷ 0.015 s = about 133 RPS per pod before overhead and GC. That is the real limit, at roughly 530 RPS for the service.
The load test should confirm the CPU ceiling: p95 bends near 500 RPS, CPU sits near 100%, and hikaricp.connections.pending stays at zero. If pending connections rise first instead, the 25 ms estimate was wrong under load, and the database is the next thing to look at. Doing this math before the run tells you which chart to watch.
Pre-test checklist
- Production profile active; no debug logging; Actuator
prometheusendpoint scraped every 10 to 15 s. server.tomcat.mbeanregistry.enabled=trueand percentile histograms enabled.- HikariCP pool sized from the database's limit ÷ instance count;
connection-timeoutshortened. -XX:MaxRAMPercentageset; GC logging on; container memory limit known.- Liveness probes on a separate management port or with a generous timeout, so they do not restart pods under load.
- Downstream services decided: real, stubbed, or excluded.
- Auth tokens or sessions and CSRF handled per virtual user.
- Ramp-up of several minutes; results read from steady state only.
- A second run planned with virtual threads enabled, if on Java 21+.
Keep everything else fixed between the two runs: same scenario, same RPS, same data. LoadTester's run comparison puts p95, error rate and RPS for both runs side by side, which makes the virtual threads on/off decision quick to read.
FAQ
What does 'Connection is not available, request timed out after 30000ms' mean?
HikariCP had no free connection for 30 seconds, its default connectionTimeout. The pool, which defaults to 10 connections, is smaller than the number of request threads that want a connection at once, or connections are held too long by slow queries or long transactions. Shorten transactions and fix slow queries first, then size the pool.
Should I enable virtual threads in Spring Boot?
On Java 21 or later, spring.threads.virtual.enabled=true is worth testing for I/O-heavy services. It removes the 200-thread Tomcat cap, so the next limit becomes your connection pool or downstream services. Run the same load test with it on and off and compare p95 and error rate.
Why is my Spring Boot app slow for the first minute of a load test?
The JVM starts by interpreting bytecode and compiles hot methods with the JIT as they run; caches, connection pools and class loading also warm up. Use a ramp-up and judge results on the steady-state period, not the first minute.
How much heap does the JVM use in a container by default?
Without -Xmx, modern JVMs set the maximum heap to 25% of the container's memory limit (MaxRAMPercentage=25). In a 2 GB container that is 512 MB of heap. Most services set -XX:MaxRAMPercentage between 60 and 75 to use the memory they pay for.
Which Actuator metrics should I watch during a load test?
http.server.requests for latency and errors per endpoint, tomcat.threads.busy against tomcat.threads.config.max, hikaricp.connections.pending and hikaricp.connections.timeout, jvm.gc.pause, and jvm.memory.used. Tomcat thread metrics need server.tomcat.mbeanregistry.enabled=true.