When latency jumps 25x, don't guess — find the one slow checkpoint, like a traffic jam at a single toll booth.
Question: API response increased from 300ms to 8 seconds. How do you debug?
Answer: I first confirm the slowdown is real by comparing the same endpoint, same payload, and the same environment, then I split the request into pieces: app code, database, and downstream services. In Spring Boot, I use Actuator metrics, logs with a trace id, thread dumps, and SQL timings to see whether the delay is caused by waiting, retries, pool exhaustion, or a slow query. Once I know the slow layer, I fix that layer and re-test under load.
Interview-Ready Answer: I’d start by confirming the regression with p95 and p99 latency for the same endpoint and payload, not just averages. Then I’d break the request path into app, database, and external calls using Spring Boot Actuator metrics, request logs, and a trace id. If CPU is low but latency is high, I’d suspect waiting — for example Hikari pool exhaustion, Tomcat thread starvation, or a downstream retry loop. After that I’d inspect thread dumps, query plans, and client timeouts, fix the bottleneck, and verify the improvement with a canary or load test.
First, make sure the 8 seconds is not a measurement bug. Compare the same API, same input, same release, and same environment. Look at p50 (typical), p95 (slow tail), and p99 (worst tail), because averages can hide spikes. A jump from 300ms to 8s often means only some requests are getting stuck, not all of them.
Think of a Spring Boot API as a chain: ingress, controller, service, database, remote calls, and response serialization. The goal is to find which link got slow. In practice, the first big question is: is the app doing more work, or is it waiting?
StopWatch or Micrometer Timer.In Spring Boot systems, the usual culprits are database waiting, thread pool saturation, and downstream retries. A very common pattern is: one slow dependency blocks threads, blocked threads fill the pool, and then even fast requests start waiting. That is how 300ms becomes 8 seconds very quickly.
| Signal | Likely cause | What to inspect |
|---|---|---|
| CPU high | Hot code or GC | Profiler, GC logs |
| CPU low | Waiting/I-O | Thread dump, pool metrics |
| Only p99 bad | Contention/retries | Tail latency, logs |
| DB queries slow | Bad query/index | EXPLAIN, query count |
| After deploy | New code path | Diff recent changes |
Enable spring-boot-starter-actuator and expose the metrics you need. Useful signals include http.server.requests, GC pause time, JVM memory, Hikari pool usage, and thread pool queue depth. If you use Spring Boot with embedded Tomcat, remember the default request thread limit is often around 200; with HikariCP, the default connection pool size is usually 10. If all 10 DB connections are busy, requests can wait even when the database itself is not broken.
BLOCKED or waiting on socketRead, locks, or the DB pool?Retries are sneaky. A client with 3 retries and a 2-second timeout can already add 6 seconds, and that is before backoff, queueing, or serialization. So if a downstream service becomes flaky, your API can become much slower even though your own code did not change. This is why I always ask: did we add retries, increase timeout values, or remove a circuit breaker?
| Tool | Best for | Limit |
|---|---|---|
| Logs | Sequence of events | No trends |
| Metrics | Latency patterns | No root cause |
| Thread dump | Blocking | Snapshot only |
| Profiler | CPU hotspots | Needs sampling |
| DB plan | Query cost | DB only |
After you find the cause, do not trust one happy-path request. Re-run with load, watch p95/p99, and compare before-and-after numbers. If the issue was in production, do a canary release or a small rollout so you can confirm the fix without risking the whole traffic path. The goal is not just to make one request fast again, but to keep it fast under real traffic.
Memory model: a request is a relay race. You time each lap, and the slowest lap decides the finish. If you do not time the laps, you will blame the wrong runner.
Performance note: Adding request timing and trace ids is usually tiny overhead, roughly O(1) per request. Full tracing and deep profiling should be sampled in production, because collecting everything can add noise and cost.
Real-World Example: Imagine an e-commerce checkout service built with Spring Boot. One morning, the checkout API jumps from 300ms to 8 seconds after a release that added a call to a payment-risk service. At first the team blames the database, but thread dumps show many request threads waiting on HTTP calls, and Hikari metrics show the app is nearly out of DB connections because slow requests are holding transactions open too long. The logs show repeated Read timed out messages and three retries per request. Users see spinning checkout buttons, delayed order confirmations, and sometimes failed payments even though the code path looks small on paper.
What went wrong is usually not one giant bug; it is a chain reaction. A slow downstream call triggers retries, retries hold threads, threads hold connections, and the whole API becomes slow. That is why production debugging is about following the request path step by step, not guessing from the last symptom you saw.
import java.io.IOException;\nimport java.util.UUID;\n\nimport jakarta.servlet.FilterChain;\nimport jakarta.servlet.ServletException;\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\n\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.slf4j.MDC;\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.core.annotation.Order;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.stereotype.Component;\nimport org.springframework.stereotype.Service;\nimport org.springframework.util.StopWatch;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.RestController;\nimport org.springframework.web.filter.OncePerRequestFilter;\nimport org.springframework.web.server.ResponseStatusException;\n\n@SpringBootApplication\npublic class LatencyDebuggerApplication {\n public static void main(String[] args) {\n SpringApplication.run(LatencyDebuggerApplication.class, args);\n }\n}\n\n@RestController\nclass OrderController {\n private final CheckoutService checkoutService;\n\n OrderController(CheckoutService checkoutService) {\n this.checkoutService = checkoutService;\n }\n\n @GetMapping("/orders/{orderId}")\n public ResponseEntity<OrderView> getOrder(@PathVariable String orderId) {\n return ResponseEntity.ok(checkoutService.load(orderId));\n }\n}\n\n@Service\nclass CheckoutService {\n private static final Logger log = LoggerFactory.getLogger(CheckoutService.class);\n\n public OrderView load(String orderId) {\n if (orderId == null || orderId.isBlank()) {\n throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "orderId must not be blank");\n }\n\n // Timing each step helps you see where the request spends time: app code, I/O, or waiting.\n StopWatch watch = new StopWatch("checkout:" + orderId);\n\n watch.start("inventory");\n int inventoryCount = callInventory(orderId);\n watch.stop();\n\n watch.start("pricing");\n int priceCents = callPricing(orderId);\n watch.stop();\n\n log.info("Breakdown for {} -> {}", orderId, watch.prettyPrint());\n return new OrderView(orderId, inventoryCount, priceCents, watch.prettyPrint());\n }\n\n private int callInventory(String orderId) {\n long sleepMs = orderId.contains("slow") ? 1200L : 80L;\n sleepOrFail(orderId, "inventory", sleepMs);\n return 7;\n }\n\n private int callPricing(String orderId) {\n long sleepMs = orderId.contains("timeout") ? 2500L : (orderId.contains("slow") ? 900L : 100L);\n sleepOrFail(orderId, "pricing", sleepMs);\n\n if (orderId.contains("timeout")) {\n // Fail fast instead of hiding a slow dependency behind a long UI spinner.\n throw new ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, "Pricing service timed out");\n }\n\n return 4999;\n }\n\n private void sleepOrFail(String orderId, String step, long millis) {\n try {\n Thread.sleep(millis);\n } catch (InterruptedException ex) {\n // Always restore interrupt status so thread-pool code and container code can see it.\n Thread.currentThread().interrupt();\n throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, step + " interrupted for order " + orderId);\n }\n }\n}\n\nrecord OrderView(String orderId, int inventoryCount, int priceCents, String diagnostics) { }\n\n@Component\n@Order(1)\nclass RequestTimingFilter extends OncePerRequestFilter {\n private static final Logger log = LoggerFactory.getLogger(RequestTimingFilter.class);\n\n @Override\n protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)\n throws ServletException, IOException {\n String traceId = request.getHeader("X-Trace-Id");\n if (traceId == null || traceId.isBlank()) {\n traceId = UUID.randomUUID().toString();\n }\n\n MDC.put("traceId", traceId);\n long startNanos = System.nanoTime();\n\n try {\n filterChain.doFilter(request, response);\n } finally {\n long tookMs = (System.nanoTime() - startNanos) / 1_000_000;\n log.info("traceId={} method={} path={} status={} took={}ms", traceId, request.getMethod(), request.getRequestURI(), response.getStatus(), tookMs);\n MDC.remove("traceId");\n }\n }\n}
Follow-up & Tricky Questions:
http.server.requests for endpoint latency, then Hikari pool metrics, GC pauses, and thread pool usage. That gives me the fastest map of where the time is going.Tricky / gotchas:
Common Mistakes:
Memory Hook: Think of the request like airport security: one slow lane can hold up the whole terminal. Don’t stare at the crowd — find the slow lane, then time each checkpoint.
Cheat Sheet:
Practice Tasks: