A method for diagnosing slowness under load, the optimisations that actually matter, a performance-improvement story, horizontal scaling, distributed session management with Spring Session, and resilience with timeouts, retries, circuit breakers and bulkheads.
Published September 25, 2026
Performance questions are really method questions. Interviewers want to hear that you measure before changing anything, find the bottleneck, fix that, and verify. A list of buzzwords ("add caching, use WebFlux") without a diagnosis is the answer they're trying to filter out.
Short answer: Follow a loop: observe → reproduce → locate → fix → verify.
EXPLAIN.Key points to cover:
Learn it in depth → Metrics & Monitoring
Short answer:
HikariPool.getConnection means the database or pool is the bottleneck. Many waiting on a RestClient call means a slow dependency.Short answer: Group them by where the time goes:
| Layer | Optimisations |
|---|---|
| Database | Fix N+1 (fetch joins, entity graphs), add the right indexes, DTO projections, pagination, JDBC batching, a right-sized HikariCP pool, read replicas |
| Caching | Caffeine/Redis for hot, read-mostly data; HTTP caching (ETags, CDN) |
| Remote calls | Timeouts, connection pooling in HTTP clients, parallel calls (CompletableFuture), bulk APIs, async messaging for non-critical work |
| Concurrency | Virtual threads (spring.threads.virtual.enabled=true) for I/O-bound apps; bounded executors |
| Payload | Response compression, lean DTOs, avoiding over-fetching |
| JVM | A suitable GC (G1 or ZGC), heap sized for the container, less allocation churn |
| Startup | Lazy initialisation, fewer auto-configurations, CDS/AOT, native images where it matters |
Common trap: "switch to WebFlux". Reactive helps only with huge numbers of concurrent, I/O-bound connections, with a fully non-blocking stack. It adds complexity, and it doesn't speed up slow SQL. Virtual threads often deliver the same scalability with plain blocking code.
Short answer: The cheapest, highest-impact fixes first:
Hardware scaling and architectural changes come after you've removed obvious waste. Each change should be justified by a measurement.
Short answer (a model story; use your own numbers): "Order-history requests hit p99 of 4 s at peak. Tracing showed about 300 SQL queries per request, an N+1 on order lines and products. I replaced it with a fetch-join query plus DTO projection, added a composite index on (customer_id, created_at), and switched to keyset pagination. We cached product details in Caffeine with a 10-minute TTL, and moved invoice-PDF generation to an async queue. The p99 dropped to 250 ms, and database CPU fell 60%. We added a query-count assertion in integration tests, so the N+1 can't come back."
Key points to cover:
Short answer:
max_connections), read replicas, caching, and eventually partitioning.Learn it in depth → Horizontal vs Vertical Scaling
Short answer: Usually the database. Twenty instances with a pool of 20 connections each means 400 connections, which can exceed database limits or thrash it. Other things that break:
Plan for these before adding instances. For example, use a connection proxy (PgBouncer, or RDS Proxy), and size the pools deliberately.
Short answer: Prefer stateless authentication (JWT or opaque OAuth2 tokens validated by each service), so there's no session to share. When you do need server-side sessions (server-rendered apps, or login flows), use Spring Session. It transparently stores HttpSession data in Redis (or JDBC or Hazelcast), so any instance can serve any request.
spring:
session:
redis:
namespace: shop:sessions
timeout: 30m
data:
redis:
host: redis.internal
Key points to cover:
HttpOnly, Secure, SameSite), and rotate the session ID at login.Short answer: Assume every dependency will fail, and contain the damage with Resilience4j (Spring Cloud Circuit Breaker):
TimeLimiter.@CircuitBreaker(name = "recommendations", fallbackMethod = "popularProducts")
@TimeLimiter(name = "recommendations")
public CompletableFuture<List<Product>> recommendations(long userId) {
return CompletableFuture.supplyAsync(() -> recoClient.forUser(userId), ioExecutor);
}
private CompletableFuture<List<Product>> popularProducts(long userId, Throwable t) {
return CompletableFuture.completedFuture(cache.topSellers()); // degrade, don't fail the page
}
Learn it in depth → Circuit Breaker with Resilience4j
Q: How do you size the HikariCP pool?
A: Start small. A common starting point is around (cores × 2) + effective spindles on the database side, often 10–20. Then tune with metrics (pending threads, connection wait time). Bigger pools often make performance worse, because the database spends its time context-switching.
Q: What does spring.threads.virtual.enabled=true change?
A: On Java 21+, Tomcat request handling, @Async executors and some other executors run on virtual threads. Blocking I/O no longer ties up scarce platform threads, but the connection pools still limit how much concurrency actually reaches the database.
Q: How do you load-test realistically? A: Use production-like data volumes and traffic mixes, ramp up gradually, include think time, run long enough to reach a steady state (and catch leaks), and watch server-side metrics, not just client response times.
Q: What is load shedding? A: Rejecting some requests early (a 429 or 503), when the system is near saturation, so the requests you accept still succeed quickly. Examples: bounded queues, rate limiters, and adaptive concurrency limits.