Performance Tuning: A Practical Method
Guessing is the enemy of performance work. This page gives you a method (measure, find the bottleneck, fix, measure again) and the checklist of typical bottlenecks in a Spring Boot service, from database to JVM.
Method
1. Define the goal "p95 latency < 300 ms at 500 requests per second"
2. Reproduce it load test (k6, Gatling, JMeter) with realistic data and traffic
3. Measure metrics + traces + profiler: WHERE is the time? (DB? CPU? waiting? GC?)
4. Fix ONE thing the biggest bottleneck only
5. Measure again did it help? if not, revert
Typical distribution in business apps: database 60% ─ remote calls 20% ─ serialization/CPU 10% ─ JVM/GC 10%Database first
- Turn on slow query logging and read query plans (EXPLAIN ANALYZE). Add missing indexes for WHERE, JOIN and ORDER BY columns.
- Kill N+1 queries (join fetch, entity graph, batch fetch size, DTO projections).
- Paginate. Never return unbounded lists. For big offsets use keyset (seek) pagination.
- Select only needed columns (projections) for read APIs.
- Batch writes: hibernate.jdbc.batch_size, SEQUENCE ids, JdbcTemplate batchUpdate.
- Keep transactions short. Do not call remote services inside a transaction.
- Size the Hikari pool. More is not better.
# log statements slower than 25 ms (Hibernate 6)
spring.jpa.properties.hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS=25
spring.jpa.properties.hibernate.generate_statistics=true
logging.level.org.hibernate.stat=DEBUG
spring.jpa.open-in-view=false// Keyset pagination: fast for deep pages (uses the index, no OFFSET scan)
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("select o from Order o where o.id < :lastId order by o.id desc")
List<Order> nextPage(@Param("lastId") long lastId, Pageable limit); // PageRequest.of(0, 50)
}
// client sends the last id it saw: GET /orders?after=12345&size=50Application layer
| Problem | Fix |
|---|---|
| Repeated identical reads | Cache (Caffeine or Redis) with TTL and eviction |
| Sequential remote calls | Run independent calls in parallel (CompletableFuture, structured concurrency, WebClient zip) |
| Slow serialization of huge JSON | Smaller DTOs, pagination, compression, streaming (StreamingResponseBody) |
| Thread pool exhaustion | Timeouts on every client, bulkheads, virtual threads (Java 21+) |
| Chatty APIs | Batch endpoints, GraphQL or composite endpoints |
| Heavy work in the request | Move to async processing (queue) and return 202 Accepted |
JVM and containers
java -XX:MaxRAMPercentage=75 \
-XX:+UseG1GC \ # default, good balance. Try -XX:+UseZGC (generational in newer JDKs) for very low pauses
-Xlog:gc*:file=gc.log:time,uptime \
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp \
-XX:StartFlightRecording=filename=rec.jfr,duration=120s,settings=profile \
-jar app.jar- Set memory from the container limit (MaxRAMPercentage), not a fixed -Xmx.
- Watch GC pause time and allocation rate. Frequent full GCs mean a leak or a heap that is too small.
- Profile with JDK Flight Recorder (open the .jfr in JDK Mission Control), async-profiler or VisualVM. Flame graphs show hot methods.
- Heap dump analysis (Eclipse MAT) finds memory leaks: static maps, unbounded caches, ThreadLocals, listeners.
HTTP and network
- Enable compression (server.compression.enabled=true) for JSON above 1 KB.
- Use connection pooling for outgoing HTTP (RestClient with Apache HttpClient or JDK client) and keep-alive.
- Use HTTP caching headers (ETag, Cache-Control) for GET responses.
- Put a CDN in front of static content.
Startup and memory footprint
- Remove unused starters and auto-configurations (fewer beans).
- Class data sharing (AppCDS) and the AOT cache reduce startup time and memory.
- GraalVM native image for fast start and low memory (serverless, scale to zero), with a longer build.
- Lazy initialization only after measuring, because it moves errors to runtime.
Load test example (k6)
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = { stages: [
{ duration: '30s', target: 50 },
{ duration: '2m', target: 200 },
{ duration: '30s', target: 0 } ],
thresholds: { http_req_duration: ['p(95)<300'], http_req_failed: ['rate<0.01'] } };
export default function () {
const res = http.get('http://localhost:8080/api/products?page=0&size=20');
check(res, { 'status is 200': (r) => r.status === 200 });
sleep(1);
}Common mistake: Optimizing without measuring, for example adding caches or threads everywhere. You add complexity and bugs and the real bottleneck (often one missing index or an N+1 query) stays. Always find the bottleneck with data first.
Interview questions
What is the first thing to check when an endpoint is slow?
The database: number of queries per request (N+1), slow queries and missing indexes. A trace or SQL log shows this quickly.
Should I increase the connection pool size to go faster?
Rarely. A bigger pool can overload the database. First remove slow queries and long transactions.
p50 or p99?
Look at percentiles. p50 is the typical user, p95 and p99 show the slow tail that hurts real users and reveals problems like GC pauses and pool waits.