Back to Scenarios & Design Questions
Q46Question 46 of 67

If the application suddenly becomes very slow, how do you debug it systematically

Key Concepts
  • Collect evidence before changing anything: a blind change can hide the real cause
  • Scope the problem first: when did it start, which endpoints, which instances, correlate with deploys
  • High CPU means the code is doing too much work; low CPU with high latency means the system is waiting
  • Near-full heap causes escalating GC pauses that look like general slowness
  • Check the database for slow queries and the connection pool for exhaustion
  • A thread dump shows every thread's state and stack: the decisive tool for pool starvation
  • Chase the root cause, not the symptom: a slow query exhausts the pool which starves threads

Definition

Debug slowness by confirming and scoping with metrics first, then narrowing outside-in through CPU, memory and GC, database and connection pool, external APIs, and thread pools using logs, thread dumps, heap analysis, and traces.
  • Evidence first – collect metrics before changing anything; a blind change hides the real cause.
  • Scope – confirm when it started, which endpoints, which instances, and correlate with deploys or cron jobs.
  • CPU – high CPU means too much work; low CPU with high latency means the system is waiting on something.
  • Memory and GC – near-full heap causes escalating GC pauses that masquerade as general slowness.
  • Database – slow queries, lock waits, missing indexes, and connection-pool exhaustion.
  • External APIs – a slow downstream makes you slow while your own metrics look fine.
  • Thread pools – a thread dump shows blocked/waiting threads, the signature of pool starvation.

Imagine the transfer counter at National Bank of India suddenly has a huge slow-moving queue. A panicking manager might randomly add staff or reboot computers. A systematic manager gathers facts: how long is the queue and since when, is it one counter or all of them, is the teller's computer frozen, is the central records room (the database) taking ages to answer, is the teller waiting on a phone call to another branch (an external API), or are all the tellers tied up waiting and none free (thread-pool starvation). Only after the facts point to one cause does the manager act.

Understanding the Concepts

What is the first step when the application is slow?

Collect evidence before changing anything. A blind change can hide the real cause, and in a bank it can corrupt money. Start by confirming and scoping: when did it start, is it all endpoints or one, all instances or one, correlated with a deploy, a traffic spike, or a scheduled job.

JAVA
package com.bank.monitoring;

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Component;

@Component
public class GoldenSignalMetrics {

    private final Timer requestTimer;

    public GoldenSignalMetrics(MeterRegistry registry) {
        this.requestTimer = Timer.builder("http.server.requests")
            .description("Request duration")
            .tag("endpoint", "all")
            .register(registry);
    }

    // Golden signals to check first:
    // 1. Latency: P50, P95, P99 per endpoint
    // 2. Traffic: requests/sec (did it spike?)
    // 3. Errors: error rate (did it jump?)
    // 4. Saturation: CPU, memory, pool utilization
}
TEXT
Scoping questions:
  When did it start?           -> correlate with deploy, cron, spike
  Which endpoints are slow?    -> all or just /api/transfers?
  Which instances are slow?    -> all or just pod-3?
  Did traffic increase?        -> latency rose at same throughput?
  Is it getting worse?         -> gradual (leak) or sudden (deploy)?

How do you check CPU?

High CPU across instances suggests the code is doing too much work. Low CPU with high latency means the system is waiting, not computing, which steers you toward I/O, locks, or dependencies.

BASH
# Check CPU per pod
kubectl top pods -n banking

# If CPU is high: use async-profiler to find hot methods
java -agentpath:/path/to/async-profiler/libasyncProfiler.so=start,event=cpu,file=profile.html -jar app.jar

# If CPU is low but latency is high:
# The system is WAITING, not computing
# Look at I/O, locks, database, external APIs
TEXT
CPU diagnostic:
  High CPU (90%+):  code doing too much work
    -> profile with async-profiler to find hot method
    -> common causes: tight loop, heavy serialization

  Low CPU (5-10%) + high latency:
    -> threads are WAITING, not working
    -> look at: database, external APIs, thread locks
    -> thread dump will show what they are waiting for

How do you check memory and GC?

If the heap is nearly full, the JVM spends escalating time in garbage collection. In the worst case it enters near-constant full GC where the application barely progresses. Check GC logs and JVM metrics.

BASH
# Check GC metrics via JMX or Actuator
curl http://localhost:8080/actuator/metrics/jvm.gc.pause
curl http://localhost:8080/actuator/metrics/jvm.memory.used

# Enable GC logging
java -Xlog:gc*:file=gc.log:time -jar app.jar

# Take a heap dump for analysis
jmap -dump:format=b,file=heapdump.hprof $(pgrep -f app.jar)
# Analyze with Eclipse MAT: find what retains memory
TEXT
Memory diagnostic:
  Heap usage trending upward, never dropping -> memory leak
  GC pause time increasing -> GC thrashing
  After deploy with rising old-gen -> classic new leak

  Action:
  1. Check GC logs for pause time and frequency
  2. Take heap dump BEFORE restarting (restart erases evidence)
  3. Open in Eclipse MAT -> find largest retained objects
  4. Common culprits: growing cache, unclosed streams, static maps

How do you check the database?

The database is one of the most common causes of sudden slowness. Check slow-query logs, lock waits, and the connection pool.

SQL
-- PostgreSQL: find slow queries
SELECT pid, now() - pg_stat_activity.query_start AS duration,
       query, state
FROM pg_stat_activity
WHERE (now() - pg_stat_activity.query_start) > interval '5 seconds'
ORDER BY duration DESC;

-- Check for lock waits
SELECT blocked.pid AS blocked_pid,
       blocking.pid AS blocking_pid,
       blocked.query AS blocked_query
FROM pg_stat_activity blocked
JOIN pg_locks blocked_locks ON blocked.pid = blocked_locks.pid
JOIN pg_locks blocking_locks ON blocked_locks.locktype = blocking_locks.locktype
JOIN pg_stat_activity blocking ON blocking_locks.pid = blocking.pid
WHERE NOT blocked_locks.granted;
JAVA
package com.bank.monitoring;

import com.zaxxer.hikari.HikariDataSource;
import com.zaxxer.hikari.HikariPoolMXBean;
import org.springframework.stereotype.Component;

@Component
public class ConnectionPoolMonitor {

    private final HikariDataSource dataSource;

    public ConnectionPoolMonitor(HikariDataSource dataSource) {
        this.dataSource = dataSource;
    }

    public void logPoolStatus() {
        HikariPoolMXBean pool = dataSource.getHikariPoolMXBean();
        System.out.println("Active connections: " +
            pool.getActiveConnections());
        System.out.println("Idle connections:   " +
            pool.getIdleConnections());
        System.out.println("Waiting threads:    " +
            pool.getThreadsAwaitingConnection());
        System.out.println("Total connections:  " +
            pool.getTotalConnections());

        // DANGER: active == total AND waiting > 0
        // means pool is exhausted!
    }
}
TEXT
Database diagnostic:
  Slow query log shows query taking 10s -> missing index?
  Active connections == max pool size -> pool exhaustion
  Threads waiting for connection -> everything queues

  Key insight: a query that got slow holds connections longer,
  which cascades into pool exhaustion, which starves all requests.
  Chase the ROOT (slow query), not the SYMPTOM (pool exhaustion).

How do you check external APIs?

A slow downstream makes your service slow while your own CPU and database look fine. Use distributed tracing to see where time is spent.

TEXT
Distributed trace waterfall:
  [Transfer API]        total: 5200ms
    [Validate]          10ms
    [Fraud Check]       200ms
    [Account Service]   4800ms  <-- THIS is the bottleneck
    [Notification]      190ms

  Account service latency spike caused transfer slowness.
  Fix: add timeout + circuit breaker to account service call.

How do you use thread dumps?

A thread dump is the decisive tool for pool starvation. It shows every thread's state and stack, so you can count how many are BLOCKED, WAITING, or RUNNABLE.

BASH
# Take thread dump
jstack $(pgrep -f app.jar) > threaddump.txt

# Take 3 dumps 5 seconds apart to see if threads are stuck
for i in 1 2 3; do
  jstack $(pgrep -f app.jar) > threaddump_$i.txt
  sleep 5
done

# Look for patterns:
grep -c "BLOCKED" threaddump.txt
grep -c "WAITING" threaddump.txt
grep -c "RUNNABLE" threaddump.txt
TEXT
Thread dump analysis:
  180 threads WAITING on database connection pool
  15 threads RUNNABLE (serving requests)
  5 threads BLOCKED on a lock

  Interpretation: pool exhaustion!
  180 threads waiting for connections because a slow query
  holds each connection too long.

  Two dumps 5s apart with same threads in same wait:
  -> threads are STUCK, not progressing
  -> confirms the starvation diagnosis

How do logs and traces connect the layers?

Structured logs with per-request latency and a correlation ID let you find slow requests and see where their time went. Distributed traces show the same picture visually.

JAVA
package com.bank.logging;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.UUID;

@Component
public class RequestTracingFilter extends OncePerRequestFilter {

    private static final Logger log =
        LoggerFactory.getLogger(RequestTracingFilter.class);

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain)
            throws Exception {
        String correlationId = UUID.randomUUID().toString();
        MDC.put("correlationId", correlationId);
        long start = System.currentTimeMillis();

        try {
            chain.doFilter(request, response);
        } finally {
            long duration = System.currentTimeMillis() - start;
            log.info("endpoint={} status={} duration={}ms",
                request.getRequestURI(),
                response.getStatus(),
                duration);
            MDC.remove("correlationId");
        }
    }
}
TEXT
Structured log output:
  correlationId=abc-123 endpoint=/api/transfers status=200 duration=5200ms
  correlationId=abc-123 db.query=SELECT balance duration=4800ms
  -> 92% of time spent in database query -> investigate that query

What is the systematic debugging loop?

Confirm and scope with metrics, form one hypothesis, gather specific evidence, confirm, make one targeted change, and verify the metric moves.

| Step | Action | Tool |
|---|---|---|
| 1. Scope | When, which endpoints, which instances | Grafana dashboard |
| 2. CPU | High or low? | kubectl top, async-profiler |
| 3. Memory | Heap full? GC thrashing? | GC logs, jmap, Eclipse MAT |
| 4. Database | Slow queries? Pool exhaustion? | pg_stat_activity, HikariCP metrics |
| 5. External | Slow dependency? | Distributed traces |
| 6. Threads | Pool starvation? | jstack thread dump |
| 7. Fix | One targeted change | Deploy, verify metric moves |

What are common mistakes when debugging?

The most common mistake is restarting the service before capturing evidence. A restart erases the thread dump, heap dump, and GC logs that would have identified the root cause.

TEXT
WRONG:
  Service is slow -> restart it -> slowness gone temporarily
  Root cause unknown -> happens again next week
  No evidence to investigate

CORRECT:
  Service is slow -> capture thread dump + heap dump + GC logs
  -> analyze -> find slow query with missing index
  -> add index -> verify latency drops
  -> root cause fixed permanently

  Always capture evidence BEFORE restarting!

Visual Overview

DIAGRAM
  Systematic Debugging Flow:

  Application Slow!
       │
       ▼
  ┌──────────────────────────┐
  │ 1. Scope with Metrics    │   When? Which endpoints? Deploy?
  │    Grafana / Prometheus   │
  └──────────┬───────────────┘
             │
       ┌─────┴─────┐
       ▼           ▼
  CPU High?    CPU Low + Latency High?
       │           │
       ▼           ▼
  Profile     System is WAITING
  (async-     │
  profiler)   ├── Check Memory/GC
              │   (gc.log, heap dump)
              │
              ├── Check Database
              │   (slow query log, pool metrics)
              │
              ├── Check External APIs
              │   (distributed traces)
              │
              └── Check Thread Pools
                  (jstack thread dump)
                       │
                       ▼
              ┌────────────────┐
              │ Root Cause     │
              │ Found!         │
              │ (e.g., missing │
              │  index)        │
              └────────┬───────┘
                       │
                       ▼
              One targeted fix
              Verify metric improves

Key Takeaway

When an application suddenly becomes very slow, the method is evidence-first and layer-by-layer. Start by confirming and scoping with metrics: when did it begin, which endpoints, which instances, and does it correlate with a deploy or traffic spike. Check the golden signals: latency, traffic, errors, and saturation.

Then narrow from the outside in. High CPU means the code is doing too much work, found with a profiler. Low CPU with high latency means the system is waiting, not computing, which points to I/O, locks, or dependencies. Check memory and GC for a nearly full heap causing escalating pauses. Check the database for slow queries and connection-pool exhaustion, which is often the root cause cascading into thread starvation. Check external APIs with distributed tracing. Take a thread dump to see what every thread is waiting on.

At National Bank of India, the transfer service went slow one evening. The on-call engineer wanted to restart, but we captured a thread dump first and saw almost every request thread waiting to borrow a database connection, with the pool pegged at its maximum. Correlating with the evening's deploy, a query had lost its index and was holding connections too long, exhausting the pool and starving all threads. Restoring the index fixed it instantly. The lessons: always capture evidence before restarting, chase the root cause not the symptom, and keep timeouts and circuit breakers in place.

Remember one sentence: debug slowness by scoping with metrics, narrowing outside-in through CPU, memory, database, external APIs, and thread pools, fix the one proven bottleneck, and always capture evidence before restarting.

---

Share
Use ← → arrow keys to navigate