Start by measuring, not guessing:
- Check latency percentiles (p95, p99) from Micrometer metrics, then a distributed trace of a slow request to see where time goes: database, remote calls, serialisation or CPU.
- Profile with async-profiler or Java Flight Recorder if it is CPU or allocation heavy.
Common causes and fixes:
- Database: N+1 queries (fetch joins, entity graphs), missing indexes (check the query plan), fetching whole entities when a DTO projection would do, and no pagination on large lists.
- Connection pool: HikariCP exhausted; check pool metrics, size it to the database, shorten transactions.
- Remote calls made one after another: run independent calls in parallel (CompletableFuture, structured concurrency or virtual threads), add timeouts, cache stable responses.
- Caching: @Cacheable with Caffeine locally or Redis for shared data, with clear invalidation and TTLs.
- Payload size: compress responses, return only needed fields.
- Threading: enable virtual threads on Java 21 for I/O-bound workloads.
- JVM: right-size heap and choose the GC (G1 or ZGC) based on GC pause metrics.
Close the loop: add a load test (Gatling or k6) to confirm the improvement and prevent regressions.