Performance Tuning Spring Boot Applications Under Load
- java
- spring-boot
- performance
- jvm
- backend
Performance problems always have a cause. The mistake is guessing instead of measuring.
I've spent hours "optimising" Spring Boot applications by adjusting JVM flags based on intuition, only to find the real problem was a single N+1 query adding 400ms per request. The tooling to find real bottlenecks exists and is accessible. Use it before changing anything.
Measure First
Three tools I reach for before touching any configuration:
async-profiler for CPU flamegraphs. Attach it to a running JVM and get a visual representation of where CPU time is actually spent. You'll find hot paths you didn't expect and confirm that your "obvious bottleneck" isn't the bottleneck at all.
# Attach to JVM process, sample for 30 seconds, output flamegraph
./asprof -d 30 -f flamegraph.html <pid>
Open the HTML file and look for the widest blocks. That's where time is going.
Micrometer + Prometheus for latency distributions. Averages lie. You need percentiles — p50, p95, p99. A p50 of 50ms and a p99 of 4000ms tells you very different things than "average response time: 90ms".
In application.properties:
management.metrics.distribution.percentiles-histogram.http.server.requests=true
management.metrics.distribution.percentiles.http.server.requests=0.5,0.95,0.99
Query in Prometheus: histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket[5m])) by (le, uri))
Thread dumps for blocking. If CPU is low but latency is high, something is blocking — waiting for a lock, waiting for a connection, waiting for an I/O response. Take three thread dumps 10 seconds apart with jstack <pid> or via /actuator/threaddump. Look for threads in BLOCKED or WAITING state on the same monitor.
Connection Pool Sizing
HikariCP is Spring Boot's default connection pool and it defaults to a pool size of 10. For most development and low-traffic scenarios, this is fine. Under load, you'll see this in your logs:
HikariPool-1 - Connection is not available, request timed out after 30000ms
This means all 10 connections are in use and new requests are waiting. The instinctive response is to increase pool size. This is sometimes wrong.
The correct formula, from the HikariCP documentation: pool_size = (core_count × 2) + effective_spindle_count. For a 4-core application server with SSD (spindle count = 1): pool_size = 9.
The counterintuitive point: more connections doesn't always mean more throughput. The database has its own connection limit. If your service runs 5 instances with a pool of 20 each, that's 100 connections to PostgreSQL. PostgreSQL's default max_connections is 100. You've just used them all.
Configure HikariCP explicitly:
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.connection-timeout=5000
spring.datasource.hikari.idle-timeout=600000
spring.datasource.hikari.max-lifetime=1800000
spring.datasource.hikari.pool-name=HikariPool-OrderService
Set pool-name so you can distinguish pools in metrics when multiple data sources are in play.
N+1 Queries — The Most Common JPA Killer
An N+1 query problem: you load 50 orders, and for each order, JPA lazily loads the associated Customer, resulting in 51 queries instead of 1.
Enable SQL logging in development to spot this pattern immediately:
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.type.descriptor.sql=TRACE
Watch for the same query repeating 50 times in rapid succession. That's your N+1.
Fix it with JOIN FETCH or @EntityGraph:
// JPQL with JOIN FETCH
@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :status")
List<Order> findByStatusWithCustomer(@Param("status") OrderStatus status);
// Or @EntityGraph on the repository method
@EntityGraph(attributePaths = {"customer", "items"})
List<Order> findByStatus(OrderStatus status);
@EntityGraph is cleaner for simple cases. JOIN FETCH gives you more control when you need to filter or paginate. Both generate a single SQL query with a join instead of N+1 separate queries.
Don't blindly eager-load everything. Loading @ManyToMany with 5000 items eagerly is worse than the N+1. Profile first, then decide what to eager-load.
JVM Flags That Matter in 2026
In containerised deployments, three flags cover most cases:
-XX:MaxRAMPercentage=75
-XX:InitialRAMPercentage=50
-XX:+UseG1GC
-Xlog:gc*:file=/logs/gc.log:time,uptime:filecount=5,filesize=20m
MaxRAMPercentage=75: caps heap at 75% of the container's memory limit. Essential in Kubernetes — without it, the JVM reads the node's total RAM (32 GB) rather than the container limit (1 GB) and sets heap accordingly. OOMKill follows.
UseG1GC: default since JDK 9. Still the right choice for most services. Provides consistent pause times and handles heap sizes from 512MB to 32GB well. Don't switch to ZGC or Shenandoah without first confirming G1 is actually the bottleneck.
Xlog:gc*: GC logging is free from a performance perspective and invaluable for diagnosing memory pressure and pause time spikes. Route GC logs to a file with rotation so they don't fill your filesystem.
Virtual Threads (JDK 21+)
If your service is I/O bound — waiting on database queries, HTTP calls, or external APIs — virtual threads can dramatically increase throughput without code changes.
spring.threads.virtual.enabled=true
That one property switches Spring MVC's thread pool from platform threads to virtual threads. Each request gets its own virtual thread, which parks on I/O without blocking an OS thread. The practical effect: you can handle hundreds of concurrent I/O-bound requests with a fraction of the RAM that a platform thread pool would require.
Virtual threads are not a silver bullet for CPU-bound work. If your endpoint is doing significant computation, virtual threads won't help — you're still bounded by available CPU cores.
Test under load before and after enabling virtual threads. In I/O-bound services I've seen p99 latency improve by 30–40% under high concurrency.
Caching with Caffeine
For hot read paths that are expensive to compute or query, @Cacheable with Caffeine:
@Cacheable(value = "product-catalog", key = "#categoryId")
public List<Product> getProductsByCategory(Long categoryId) {
return productRepository.findByCategoryId(categoryId);
}
@CacheEvict(value = "product-catalog", key = "#product.categoryId")
public Product updateProduct(Product product) {
return productRepository.save(product);
}
Configure Caffeine with explicit TTL and size limits:
spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=300s
Cache invalidation is where bugs live. If you cache a List<Product> by categoryId, every write to a product in that category must evict that cache entry. Missing a write path is how you serve stale data for 5 minutes and wonder why your UI is wrong.
Tuning order: measure, find the actual bottleneck, fix the bottleneck. Nine times out of ten it's a database query or a misconfigured connection pool — not the JVM flags.
You can always make it faster. The question is whether the current performance is actually a problem. Profile in production under real load, then decide what's worth fixing.