0x55aa
Back to Blog

🔬 Profiling Production Without Taking It Down With You

|
5 min read

🔬 Profiling Production Without Taking It Down With You

Here's a sentence every backend engineer has said with a straight face: "It's fast locally." Cool story. Locally you have one user, a warm cache, a database with eleven rows in it, and zero contention. Production has forty thousand concurrent requests, a connection pool that's basically a nightclub with a bouncer, and a p99 latency graph that looks like a heart monitor during a jump scare.

The bug only exists under real load, with real data shapes, hitting real network hops. Which means the only place you can actually catch it is the one place where attaching a profiler feels like defusing a bomb while it's still ticking. Attach the wrong tool, or attach the right tool wrong, and you don't get an answer — you get a second incident stacked on top of the first one.

So let's talk about doing this without becoming the reason for tomorrow's postmortem.

Rule one: sampling, not tracing

The first mistake people make is reaching for whatever profiler they used in a tutorial, which is usually an instrumenting/tracing profiler — the kind that hooks every function call, every allocation, every everything. That's fine on your laptop running a test suite. In production, under real concurrency, it can add 10-30x overhead. You're not measuring your app anymore, you're measuring your app wearing a lead suit.

What you want is a sampling profiler — one that interrupts the process every N milliseconds, grabs a stack trace, and moves on. It doesn't hook anything. Overhead is typically 1-3%, which is boring enough to run on a live node.

In Node.js, this is built in:

# Attach to a running process without restarting it
node --prof-process isolate-*.log > processed.txt

# Or, safer for prod: start with sampling on from boot
node --prof server.js

But --prof writes a V8 log that's genuinely painful to read raw. What you actually want is a flame graph, and the least dramatic way to get one on a running process is 0x or the built-in node --cpu-prof:

node --cpu-prof --cpu-prof-dir=./profiles --cpu-prof-name=req-spike.cpuprofile server.js

That .cpuprofile file drops straight into Chrome DevTools' Performance tab, or speedscope, and gives you a flame graph without ever touching your request path with instrumentation code.

Rule two: profile on purpose, not by accident

Don't just leave a profiler running forever "in case something happens." Even sampling profilers have a cost, and more importantly, an always-on profiler is a liability nobody remembers to turn off until someone asks "why is this pod using 15% more CPU than its twin." Profile with intent:

  • Time-boxed: attach for 30-60 seconds during the actual slow window, not all day.
  • Signal-triggered: on Linux you can send SIGUSR2 to a running Node process and have it start/stop a CPU profile on demand, so you don't need a deploy to go profile something.
  • One instance, not the fleet: pull one pod out of the load balancer's happy path (or just pick one and accept the extra overhead there), profile it, and leave its fifty siblings alone.
process.on('SIGUSR2', () => {
  const session = new (require('inspector').Session)();
  session.connect();
  session.post('Profiler.enable', () => {
    session.post('Profiler.start', () => {
      setTimeout(() => {
        session.post('Profiler.stop', (err, { profile }) => {
          fs.writeFileSync(`./profiles/${Date.now()}.cpuprofile`, JSON.stringify(profile));
          session.disconnect();
        });
      }, 30_000);
    });
  });
});

Send kill -USR2 <pid>, wait 30 seconds, pull the file, done. No restart, no redeploy, no telling the on-call rotation you're about to "try something."

Rule three: continuous profiling is a different tool, not a bigger hammer

If you find yourself doing this dance every week, that's a sign you want continuous profiling infrastructure instead — something like Pyroscope, Datadog Continuous Profiler, or Grafana's stack, which samples at low frequency (think 100Hz, ~1% overhead) across your whole fleet, all the time, and lets you diff flame graphs between "before the incident" and "during the incident" after the fact. The key difference from what I described above: continuous profilers are built and load-tested specifically to run forever at low overhead, whereas ad-hoc profiling scripts are built to run for thirty seconds and then get out of the way. Don't confuse the two — running an ad-hoc profiler continuously is how you get the overhead of tracing with the false confidence of sampling.

When we rolled continuous profiling out for a latency-sensitive API at Cubet, the win wasn't finding one dramatic bug — it was being able to answer "was this slow before last Tuesday's deploy?" with an actual flame graph diff instead of a Slack thread full of vibes.

The database is probably also lying to you

CPU profiling only tells half the story if your bottleneck is I/O-bound, which in backend work it usually is. If your flame graph shows a fat, boring stack sitting inside pg.query or mongoose with nothing interesting underneath it, stop profiling the app and go profile the database. EXPLAIN ANALYZE is the sampling profiler of the SQL world — cheap, safe to run against production (on a read replica if you're nervous), and it'll tell you in one shot whether you're doing a sequential scan on eight million rows because someone forgot an index six months ago.

Don't be a hero, be a scientist

The pattern underneath all of this is the same: minimize blast radius, time-box the experiment, and only ever collect what you need to answer the specific question you're asking. Profiling production isn't a special skill so much as it's regular debugging with the stakes turned up — hypothesis, minimal probe, read the result, stop.

Next time something's mysteriously slow only in prod, resist the urge to SSH in and start flailing. Pick one instance, send it a signal, give it thirty seconds, and let the flame graph do the talking. Your on-call rotation will thank you, and so will future-you, who won't be writing the postmortem for the profiler instead of the original bug.

Share:LinkedInXHacker News

Thanks for reading!

Back to all posts