Back to Thread Troubleshooting
Q3Question 3 of 5

Debug slow app with no errors?

Key Concepts
  • Logs show what happened but hide where time is accumulating. The question is 'where is time hiding?' not 'why is it slow?'
  • Hidden latency sources: thread pool queue wait, DB connection pool wait, silent retries, DNS/TLS handshake, and GC pauses.
  • Segment the request timeline: queue wait + connection wait + DB query + downstream HTTP + serialization. Each segment must be measured.
  • Distributed tracing (Jaeger/Zipkin) shows a span waterfall where every segment's actual time is visible, including gaps between segments.
  • A 2500ms request may have only 200ms of actual DB query time, with 800ms queue wait, 500ms connection wait, and 900ms in hidden retries.
  • Silent retries are a common hidden source: 3 retries at 300ms each add 900ms that appears as a single 900ms HTTP call.
  • The fix depends on which segment dominates: resize pool for queue wait, increase connections for connection wait, fix query for DB time.

Definition

When the app feels slow but logs show no errors, the problem is hidden wait time between steps, and the fix is to segment the request timeline and use distributed tracing to find where time accumulates.
  • Hidden latency – log.info proves a step happened but hides the GAPS: time in the thread pool queue, waiting for a DB connection, silent retries, DNS resolution, TLS handshake, and GC pauses.
  • Timeline segmentation – break the total request time into measurable segments: queue wait, connection acquisition, DB query, downstream HTTP call, serialization. Each segment is measured separately.
  • Distributed tracing – tools like Jaeger and Zipkin show a span waterfall where each segment's actual duration is visible, including gaps and hidden retries within a single span.
  • Common culprits – queue wait (thread pool saturated, tasks wait before execution), connection wait (HikariCP exhausted), silent retries (Spring Retry retries 3 times invisibly), DNS/TLS (first request to new host), GC pause (stop-the-world collection).

A customer at National Bank of India says it takes 10 minutes to get their balance. The teller says "I processed it in 30 seconds!" But: the customer waited in line for 8 minutes (queue wait), the teller called the vault 3 times because the first 2 calls failed (silent retries), and the first teller transferred to a second teller (handoff gap). The teller's log says "30 seconds processing" but the 8-minute line wait is invisible. Distributed tracing is a security camera recording the entire customer journey.

Understanding the Concepts

How do you debug a slow app with no errors?

The first step is to stop asking "why is it slow?" and instead ask "where is time accumulating?" Instrument each segment of the request and measure it separately.

JAVA
package com.bank.diagnostics;

public class InstrumentedServiceDemo {
    public String getBalance(String accountId) {
        long totalStart = System.nanoTime();

        // Segment 1: queue wait (time between submission and execution start)
        long queueWait = System.currentTimeMillis() - taskSubmitTime;

        // Segment 2: DB connection acquisition
        long connStart = System.nanoTime();
        // Connection conn = dataSource.getConnection();
        long connWait = (System.nanoTime() - connStart) / 1_000_000;

        // Segment 3: DB query
        long queryStart = System.nanoTime();
        // ResultSet rs = stmt.executeQuery(sql);
        long queryTime = (System.nanoTime() - queryStart) / 1_000_000;

        // Segment 4: downstream HTTP (may include hidden retries)
        long httpStart = System.nanoTime();
        // String response = httpClient.send(request);
        long httpTime = (System.nanoTime() - httpStart) / 1_000_000;

        long total = (System.nanoTime() - totalStart) / 1_000_000;
        System.out.printf("Total: %dms (queue: %dms, conn: %dms, "
                + "query: %dms, http: %dms)%n",
                total, queueWait, connWait, queryTime, httpTime);
        return "balance";
    }

    private long taskSubmitTime = System.currentTimeMillis();
}

What does distributed tracing show?

A span waterfall from Jaeger or Zipkin visualizes every segment's duration, including gaps between segments and hidden operations within a single span.

DIAGRAM
[───── Request: 2500ms ──────────────────────────────────────────]
  [── Queue wait: 800ms ──]
                           [── Conn wait: 500ms ──]
                                                   [─ DB: 200ms ─]
                                                                  [──── HTTP: 900ms ────]
                                                                  [retry1:300][retry2:300][ok:300]

The biggest contributor is queue wait (800ms), not the DB query (200ms). Without segmentation, you would optimize the DB query and gain only 200ms.

What are the hidden latency sources?

Six common sources of hidden latency that do not appear in application logs:

  1. Queue wait: task submitted to thread pool, waits in queue before a worker picks it up.
  2. Connection wait: getConnection() blocks while all connections are in use.
  3. Silent retries: Spring Retry or Resilience4j retry 3x transparently. 3 x 300ms = 900ms.
  4. DNS/TLS: first request to a new host triggers DNS lookup + TLS handshake.
  5. GC pause: stop-the-world GC freezes all threads for 200ms+.
  6. Thread scheduling: OS does not schedule the thread immediately after it becomes runnable.

What are common interview follow-up questions?

  • How do you add tracing to an existing application? Use OpenTelemetry auto-instrumentation, which adds spans to HTTP calls, DB queries, and messaging without code changes.
  • Is logging enough? No. Logs show events but not the gaps between them. Tracing shows the full request timeline.

What are common mistakes?

A common mistake is not understanding the concept fully before using it in production banking applications.

Wrong approach:

JAVA
package com.bank.service;

public class TransferService {
    public void transfer(String fromAccount, String toAccount, double amount) {
        // WRONG: using double for money
        double balance = getBalance(fromAccount);
        balance = balance - amount;
    }
    private double getBalance(String id) { return 10000.0; }
}

Correct approach:

JAVA
package com.bank.service;

import java.math.BigDecimal;

public class TransferService {
    public void transfer(String fromAccount, String toAccount, BigDecimal amount) {
        // CORRECT: using BigDecimal for money
        BigDecimal balance = getBalance(fromAccount);
        balance = balance.subtract(amount);
    }
    private BigDecimal getBalance(String id) { return new BigDecimal("10000.00"); }
}

Always use BigDecimal for monetary calculations in banking systems to avoid floating-point precision errors.

What should a senior engineer know?

A senior engineer working on banking systems should understand the production implications of this concept.

  • Performance impact. Measure with JMH before making assumptions about which approach is faster.
  • Thread safety. In a multi-threaded banking service processing concurrent transactions, always consider whether the approach is thread-safe.
  • Memory footprint. Use JFR and VisualVM to measure actual memory usage under realistic banking workloads.
  • Monitoring. Set up JMX metrics and alerts for production banking applications.
JAVA
package com.bank.monitoring;

import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;

public class MemoryMonitor {
    public static void printMemoryUsage() {
        MemoryMXBean mem = ManagementFactory.getMemoryMXBean();
        System.out.println("Heap: " + mem.getHeapMemoryUsage());
        System.out.println("Non-Heap: " + mem.getNonHeapMemoryUsage());
    }
}

What are the edge cases?

Edge cases can cause unexpected behavior in banking applications. Here are some to watch for.

JAVA
package com.bank.edge;

public class EdgeCaseDemo {
    public static void main(String[] args) {
        // Edge case 1: null handling
        String accountId = null;
        try {
            System.out.println(accountId.length());
        } catch (NullPointerException e) {
            System.out.println("Null account ID");
        }

        // Edge case 2: empty vs null
        String emptyId = "";
        System.out.println(emptyId.isEmpty());  // true
        System.out.println(emptyId == null);     // false

        // Edge case 3: boundary values
        java.math.BigDecimal zero = java.math.BigDecimal.ZERO;
        java.math.BigDecimal negativeBalance = new java.math.BigDecimal("-0.01");
        System.out.println(zero.compareTo(negativeBalance) > 0);  // true
    }
}
TEXT
Null account ID
true
false
true

Always test with null values, empty strings, boundary values, and negative amounts in banking applications.

Visual Overview

DIAGRAM
  Request Timeline Segmentation
  ═════════════════════════════

  Client request ──→ [queue: 800ms] [conn: 500ms] [DB: 200ms] [HTTP: 900ms]
                                                                    │
                                                               [retry 1: 300ms]
                                                               [retry 2: 300ms]
                                                               [success: 300ms]
  Total: 2400ms
  Biggest contributor: queue wait (800ms) ← fix pool size!
  Invisible in logs: retries (900ms appears as single HTTP call)

Key Takeaway

When the app feels slow but logs show no errors and metrics look normal, the problem is hidden wait time between processing steps. Logs prove that a step happened but hide the gaps: queue wait, connection pool wait, silent retries, and GC pauses. The right question is "where is time accumulating?" not "why is it slow?"

Distributed tracing (Jaeger/Zipkin) is the definitive tool. A span waterfall shows every segment's actual duration, including gaps between segments and hidden retries within a single call. A 2500ms request may have only 200ms of DB query time, with 800ms spent waiting in the thread pool queue, 500ms waiting for a DB connection, and 900ms in 3 transparent retries.

At National Bank of India, loan approval P99 jumped from 500ms to 5 seconds. Logs showed 600ms of service calls. Jaeger revealed the missing 4400ms: 2000ms queue wait, 1500ms connection wait, and 900ms in hidden retries. Fixing pool sizing, increasing HikariCP connections, and adding retry metrics brought P99 from 5 seconds to 400ms.

Remember one sentence: when the app is slow but logs look fine, segment the request timeline to find hidden queue waits, connection waits, and silent retries.

---

Share
Use ← → arrow keys to navigate