Elastic APM is great for seeing that a transaction is slow, but pinpointing the why at the code line requires a bit of digging.

Think of it like this: APM tells you a particular route in your web app is taking 5 seconds when it should take 50 milliseconds. It might show you a big chunk of that time spent in your database, or in an external API call. But what if the database query is fast, and the external API is fast, yet the total transaction is still slow? That’s where code-level profiling comes in. Elastic APM’s profiler attaches to your application and records exactly where CPU time is being spent, all the way down to individual function calls and loop iterations.

Let’s say you’re using a Java application with the Elastic APM agent. You’ve got a slow transaction, and the APM UI shows you a high percentage of time spent in a specific service method, but no obvious external calls are the culprit.

Here’s a typical scenario: a recursive function that’s not properly tail-optimized, or a loop that’s doing an excessive amount of work per iteration.

Scenario 1: Unbounded Recursion or Deep Recursion

Your transaction trace shows a method like processItems(List<Item> items) is taking a long time. Within that method, it calls itself for each item. If the list is large and the recursion isn’t optimized, you can blow through the stack limit or just spend a colossal amount of CPU time on function call overhead.

  • Diagnosis: Look at the "Flame Graph" or "Traces" view in APM for that slow transaction. You’ll see a deep, branching call stack originating from your processItems method. The CPU time will be concentrated in the recursive calls themselves, not in any specific line within the method body.

  • Fix: Rewrite the recursive function iteratively. Instead of processItems(items) calling processItems(remainingItems), use a for loop or a while loop with a queue or stack data structure to manage the work. For example, in Java, you might transform:

    void processItems(List<Item> items) {
        if (items.isEmpty()) return;
        processSingleItem(items.get(0));
        processItems(items.subList(1, items.size()));
    }
    

    into:

    void processItemsIterative(List<Item> items) {
        for (Item item : items) {
            processSingleItem(item);
        }
    }
    

    This eliminates the function call overhead and stack depth issues.

  • Why it works: Iterative solutions generally have less overhead than recursive ones, especially in languages that don’t perform aggressive tail-call optimization. Each function call incurs stack frame creation and destruction, which adds up.

Scenario 2: Inefficient Data Structure or Algorithm

You’re processing a large collection, and a particular loop is burning CPU. The APM trace points to a method that iterates through a List and performs a contains() check on another List inside the loop.

  • Diagnosis: The flame graph will show significant time spent within the contains() method of a java.util.ArrayList (or similar) inside your loop. If the inner list is large, contains() is an O(n) operation, making the total complexity O(n*m) where n and m are the sizes of the lists.

  • Fix: Replace the List used for the contains() check with a java.util.HashSet. HashSet.contains() is an average O(1) operation.

    Change this:

    List<String> allowedValues = Arrays.asList("a", "b", "c", ...); // potentially large
    for (String item : data) {
        if (allowedValues.contains(item)) { // O(m) lookup
            // ... process ...
        }
    }
    

    To this:

    Set<String> allowedValues = new HashSet<>(Arrays.asList("a", "b", "c", ...)); // O(1) average lookup
    for (String item : data) {
        if (allowedValues.contains(item)) {
            // ... process ...
        }
    }
    

    The initial cost of building the HashSet is amortized over the loop.

  • Why it works: Hash sets provide near-constant time complexity for membership testing, drastically reducing the time spent checking if an element exists in the collection.

Scenario 3: Excessive Object Creation in a Hot Loop

Your APM trace shows a lot of time spent in object allocation and garbage collection, often manifesting as time spent in methods like java.lang.Object.<init>() or within the GC threads.

  • Diagnosis: The flame graph will show a significant portion of CPU time attributed to object creation (<init>) or garbage collection activities. This usually happens when objects are being instantiated repeatedly within a tight loop, especially if they are large or numerous.

  • Fix: Reuse objects where possible. If you’re creating temporary objects within a loop (e.g., for string manipulation, data transformation), consider creating them once outside the loop and reusing them, or use object pooling if appropriate. For example, instead of creating a new StringBuilder in every iteration:

    for (int i = 0; i < 10000; i++) {
        StringBuilder sb = new StringBuilder(); // New object every time
        sb.append("Processing item ").append(i);
        // ... use sb ...
    }
    

    Use:

    StringBuilder sb = new StringBuilder(); // Reused object
    for (int i = 0; i < 10000; i++) {
        sb.setLength(0); // Clear previous content
        sb.append("Processing item ").append(i);
        // ... use sb ...
    }
    
  • Why it works: Reducing the number of short-lived objects decreases the frequency and duration of garbage collection cycles, freeing up CPU time that would otherwise be spent managing memory.

Scenario 4: Blocking I/O in a Synchronous Thread

While APM often highlights external calls, sometimes the way they are called within a synchronous, CPU-bound thread causes the problem. If you’re performing a long-running synchronous I/O operation (like reading a large file from disk or a slow network socket) within a thread that’s also doing computation, that thread is effectively blocked and cannot do other work, leading to perceived slowness and high CPU usage elsewhere as other threads try to pick up the slack or the system waits.

  • Diagnosis: The APM trace might show a significant "wall clock" time for a synchronous I/O operation, but the CPU time within that specific I/O thread might not be exceptionally high. However, the overall transaction is slow. You might see other threads in your application becoming busy or the thread pool becoming saturated.

  • Fix: Move blocking I/O operations to dedicated I/O threads or use asynchronous I/O. In Java, this often means using CompletableFuture with an ExecutorService for background I/O tasks, or leveraging reactive programming frameworks. For example, if you’re fetching data from a database synchronously:

    // In a web request thread
    List<Data> data = database.fetchDataSynchronously(query); // Blocks the thread
    process(data);
    

    Change to:

    // In a web request thread
    CompletableFuture<List<Data>> futureData = CompletableFuture.supplyAsync(() -> database.fetchDataSynchronously(query), ioExecutor);
    List<Data> data = futureData.join(); // Or use thenApply/thenCompose for non-blocking chaining
    process(data);
    
  • Why it works: Offloading blocking operations to separate threads prevents them from monopolizing critical request-handling threads, allowing the application to remain responsive and utilize CPU resources more effectively.

Scenario 5: Hotspots in Compiled Code (JIT)

Sometimes, the profiler points to methods that seem very simple, yet consume a lot of time. This can happen with highly optimized Java code where the Just-In-Time (JIT) compiler has heavily optimized certain loops or critical sections. The CPU time is spent executing the highly optimized machine code.

  • Diagnosis: The flame graph shows a method that’s very short, perhaps a simple getter or a loop with basic arithmetic, but it’s at the top of the CPU usage. The stack trace might include internal JVM methods related to JIT compilation or execution.
  • Fix: This is less about changing the code’s logic and more about understanding what the JIT is optimizing. Often, it means the code is being executed extremely frequently. The fix might be to reduce the number of times this hot path is hit, or to look for algorithmic improvements that reduce the overall work, even if the individual operations are fast. For instance, if a get() method on a cached object is a hotspot, the problem isn’t the get() itself, but that you’re calling it millions of times when perhaps the data could have been fetched in bulk.
  • Why it works: You’re not optimizing the "hotspot" directly, but reducing its frequency of execution by improving the overall architecture or data access patterns.

Scenario 6: Inefficient String Concatenation

A classic Java performance pitfall. Repeatedly concatenating strings using the + operator within a loop creates many intermediate String objects.

  • Diagnosis: The flame graph shows significant time spent in java.lang.String.concat() or related string manipulation methods, often within a loop.

  • Fix: Use StringBuilder for concatenating strings in loops.

    Incorrect:

    String result = "";
    for (String part : parts) {
        result += part; // Creates new String objects repeatedly
    }
    

    Correct:

    StringBuilder sb = new StringBuilder();
    for (String part : parts) {
        sb.append(part); // Efficiently appends to the internal buffer
    }
    String result = sb.toString();
    
  • Why it works: StringBuilder modifies its internal buffer directly, avoiding the creation of new String objects for each concatenation.

After you’ve addressed these, the next thing you’ll likely see is a slow transaction that’s being caused by a poorly configured thread pool, leading to thread contention.

Want structured learning?

Take the full Elastic-apm course →