loke.dev
Header image for The Day My App Profiled Itself: How I Finally Captured a Production Stack Trace

The Day My App Profiled Itself: How I Finally Captured a Production Stack Trace

Stop guessing which functions are slowing down your users and start collecting sampling profiler traces directly from the wild.

· 4 min read

The Day My App Profiled Itself: How I Finally Captured a Production Stack Trace

I spent three hours last Tuesday trying to figure out why a single button click took six seconds for a user in Berlin, while it was instant for me in New York. My local Chrome DevTools showed a pristine, empty flame graph, but the telemetry reports were screaming about "Long Tasks." I realized I was flying blind because I was only looking at my own machine, not the chaos of a low-end laptop struggling with a 5,000-row table.

We’ve all been there. "Works on my machine" is the industry's favorite shrug, but it doesn't help the user whose browser is currently melting. To fix performance issues in the wild, you need to see what’s actually running on the user’s CPU.

Enter the JS Self-Profiling API.

The Ghost in the Machine

Most of us rely on synthetic benchmarks or local profiling. But local profiling is a lie. Your M2 MacBook Pro doesn't care about that inefficient recursive function, but a three-year-old budget phone certainly does.

The JS Self-Profiling API (currently supported in Chromium browsers) allows your application to profile *itself* while the user is interacting with it. It’s a sampling profiler, meaning it takes snapshots of the call stack at regular intervals rather than recording every single function call. This keeps the overhead low enough to actually use in production.

Getting the Profiler Running

You can't just start recording everything—that would be a privacy and performance nightmare. You have to explicitly initialize the profiler.

Here is how you kick things off:

async function startProfiling() {
  if (typeof window.Profiler === 'undefined') {
    console.warn("Self-profiling isn't supported in this browser.");
    return;
  }

  // sampleInterval is in milliseconds. 10ms is a good balance.
  // maxBufferSize is the limit of samples to collect.
  const profiler = new Profiler({ 
    sampleInterval: 10, 
    maxBufferSize: 10000 
  });

  return profiler;
}

The sampleInterval is your granularity. A 10ms interval means you’ll catch most things that cause a frame drop (16.6ms). If you go too low (like 1ms), you'll put more pressure on the CPU you're trying to measure.

Capturing the "Slow" Moments

You don't want to profile the entire session; you want to profile the specific interaction that feels sluggish. I like to wrap suspicious logic in a trace.

async function handleHeavyTask() {
  const profiler = await startProfiling();

  // Run the logic you suspect is slow
  performMassiveDataTransform();

  if (profiler) {
    const trace = await profiler.stop();
    sendTraceToAnalytics(trace);
  }
}

Making Sense of the Data

When you call profiler.stop(), you don't get a pretty flame graph. You get a JSON object that looks like it was designed by a mathematician trying to save every byte possible (which, to be fair, it was).

The trace object contains three main arrays: frames, stacks, and samples.
- Frames: The actual function names and files.
- Stacks: Combinations of frames (representing the call stack).
- Samples: The actual data points mapped to a timestamp and a stack ID.

Here’s a quick-and-dirty helper to see which functions were active most often:

function analyzeTrace(trace) {
  const hits = {};

  trace.samples.forEach(sample => {
    const stack = trace.stacks[sample.stackId];
    if (!stack) return;

    // Get the top-most frame in the stack
    const frameId = stack.frameId;
    const frame = trace.frames[frameId];
    
    const name = frame.name || 'anonymous';
    hits[name] = (hits[name] || 0) + 1;
  });

  // Sort by frequency
  return Object.entries(hits).sort((a, b) => b[1] - a[1]);
}

The "Gotcha": Security Headers

This is the part that trips everyone up. You can't just drop this code into a script and expect it to work. Because the profiler can technically see cross-origin information (like how long a specific third-party script took to run), browsers require a specific security header to be present on the document:

Document-Policy: js-profiling

Without this header, window.Profiler will remain undefined, and you'll be back to guessing why your app is slow.

Should You Profile Everyone?

God, no. Profiling consumes memory and a bit of CPU. If you enable this for 100% of your users, you might actually *cause* the performance lag you’re trying to solve.

The smart move is to use Probabilistic Profiling:

1. Only profile a small % of users (e.g., 1%).
2. Or, only trigger a profile when a "Long Task" is detected via the PerformanceObserver.

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 100) {
      console.log('Long task detected! Next time, I should profile this.');
    }
  }
});

observer.observe({ entryTypes: ['longtask'] });

The Payoff

By implementing this, I found out that the "six-second button click" in Berlin wasn't a slow network request. It was a Intl.DateTimeFormat object being recreated 4,000 times inside a loop. On my machine, the loop ran in 50ms. On the user's machine, the constructor overhead was massive.

Stop squinting at your local console and start letting your app tell you where it hurts. The data is already there; you just have to ask for it.