Written by
Halkwinds Editorial Team
Halkwinds Research & Editorial

Performance Profiling: Finding Bottlenecks in Production Systems
How to profile running applications safely — flame graphs, profiling tools by runtime, and the workflow from symptom to root cause.
Your P99 latency just doubled. Support tickets are stacking up, the dashboard shows CPU pinned at 85%, and someone in the incident channel is asking whether you should "just add more pods." You could scale horizontally and buy yourself breathing room, but you'd be spending money to paper over a problem you don't understand. The faster path — and often the cheaper one — is to profile the running system and find out exactly where the time and resources are going. This article walks engineering managers through how to profile production systems safely, which tools fit which runtime, and the workflow that takes you from a vague symptom to a concrete root cause you can hand to your team.
- Background / Why This Matters
- Prerequisites and Planning
- Step-by-Step Implementation
- Testing and Validation
- Common Mistakes / What to Avoid
- Frequently Asked Questions
- Conclusion
Background / Why This Matters
Performance profiling in production is fundamentally different from profiling on a developer's laptop. In development, workloads are synthetic, data volumes are small, and the code paths you exercise rarely match what real traffic triggers. Bottlenecks that dominate in production — cache misses under concurrency, lock contention at scale, a slow downstream dependency, garbage collection pauses on a full heap — are often invisible locally.
This is why performance profiling in production matters so much. The only place you can observe the true distribution of work is where the real work happens. Estimates vary, but experienced teams consistently report that the actual hot path frequently surprises them: the function everyone "knew" was slow turns out to account for 3% of CPU time, while an innocuous serialization helper eats 40%.
For an engineering manager, the stakes are organizational as much as technical:
- Cost. Over-provisioning to mask a bottleneck compounds monthly. A single inefficient hot path can inflate a cloud bill by a meaningful margin across a fleet.
- Reliability. Latency regressions erode SLOs and customer trust long before they trigger a hard outage.
- Team focus. Without data, optimization becomes opinion-driven. Engineers argue about theoretical hot spots instead of fixing the measured one.
Actionable takeaway: Treat profiling as a first-class diagnostic capability, not a last resort. The teams that recover fastest from performance incidents are the ones that already know how to attach a profiler to a live process without a fire drill.
Prerequisites and Planning
Before anyone attaches a profiler to a production process, get three things in order: access, safety guardrails, and a hypothesis.
Access and permissions
Most low-overhead profilers need elevated privileges — perf requires access to kernel performance counters, and tools like py-spy read another process's memory, which typically needs SYS_PTRACE capability inside containers. Decide in advance:
- Who is authorized to profile production, and through what break-glass process.
- Whether your container runtime grants
CAP_SYS_PTRACE(many hardened images strip it). - Whether you profile in-place or route traffic to a canary instance you can safely stress.
Overhead budget
Sampling profilers are cheap; instrumentation profilers can be expensive. Set an explicit overhead budget — for example, "no more than a few percent of CPU and no measurable latency impact." Sampling at 99 Hz rather than 999 Hz is a good default that captures useful data with minimal disruption.
Form a hypothesis
Profiling without a question wastes time. Start from the symptom and write down what you expect. "CPU is high on the checkout service; I suspect JSON serialization in the order-confirmation path." A hypothesis tells you what to look for in the flame graph and prevents you from drowning in noise.
Rule of thumb: know what "good" looks like before you profile. Capture a baseline profile during normal operation so the anomalous profile has something to compare against.
Actionable takeaway: Prepare a runbook that documents access, capabilities, overhead limits, and a baseline capture step. When the incident hits, you follow the runbook instead of improvising with root access at 2 a.m.
Step-by-Step Implementation
The workflow below moves from broad symptom to specific root cause. It applies regardless of runtime; the tools change, the sequence doesn't.
Step 1: Classify the bottleneck
Before choosing a tool, determine what kind of resource is saturated. Use existing metrics — Datadog, Prometheus, or your APM of choice — to answer: is this CPU-bound, memory/GC-bound, I/O-bound, or lock-bound? A service pinned at high CPU with low wait time points to a CPU profiler. A service with low CPU but high latency points to off-CPU analysis or a slow dependency.
Step 2: Pick the right profiler for the runtime
Matching the tool to the runtime is where most teams stumble. Here is a practical comparison:
| Runtime | Recommended tool | Attach method | Overhead | Notes |
|---|---|---|---|---|
| Python | py-spy | External, no code change | Low (sampling) | Reads target process memory; py-spy dump is great for stuck processes |
| JVM (Java/Kotlin/Scala) | async-profiler | Attach to PID or agent | Low | Avoids safepoint bias; profiles CPU, alloc, and locks |
| Native / Go / mixed | perf | System-wide or per-PID | Low–medium | Kernel-level, sees syscalls and native frames |
| Any (continuous) | Datadog Continuous Profiler | Always-on agent | Low | Correlates profiles with traces and metrics; no ad-hoc attach needed |
Step 3: Capture a profile
For a CPU-bound Python service, attaching py-spy is a single command that produces an interactive flame graph without restarting the process:
py-spy record -o profile.svg --pid 1234 --duration 60captures a 60-second flame graph.- For the JVM,
asprof -d 60 -f flame.html 1234attaches async-profiler to PID 1234. - For native or Go workloads,
perf record -F 99 -p 1234 -g -- sleep 60thenperf scriptinto a flame graph generator.
Capture during the period the symptom is active. A profile taken after traffic subsides tells you nothing about the incident.
Step 4: Read the flame graph
A flame graph stacks call frames vertically (caller to callee) and uses width to represent the proportion of samples spent in each frame. You are hunting for wide plateaus — a single frame or subtree consuming a disproportionate share of width. Ignore tall-but-thin towers; deep call stacks that are narrow aren't your problem.
- Wide leaf frame = code actually burning CPU there.
- Wide frame with narrow children = time spent in that function's own body.
- Unexpected library at the base = a dependency dominating the hot path.
Step 5: Confirm off-CPU time if the flame graph looks empty
If the CPU flame graph is unremarkable but latency is high, your service is waiting, not computing. Use async-profiler's wall-clock mode or perf's off-CPU analysis to see where threads block — database calls, lock acquisition, or network waits. This is the single most common reason teams "can't find" a bottleneck: they profile CPU when the problem is blocking I/O.
Step 6: Trace from hot frame to code owner
Once you have a wide frame, map it to a source location and the team that owns it. Correlating the profile with distributed traces — something Datadog does well by linking a slow span to the profile captured during it — turns "this function is slow" into "this endpoint's serialization step under this tenant is slow."
Actionable takeaway: Standardize on one profiler per runtime and put the exact capture commands in your runbook. Ad-hoc tool selection during an incident wastes the first 30 minutes.
Testing and Validation
Finding a bottleneck is only half the job. You must prove the fix worked and that it didn't move the bottleneck elsewhere.
Validate against a baseline
Compare the pre-fix and post-fix flame graphs side by side. The frame you targeted should shrink measurably. If it doesn't, you fixed the wrong thing. Differential flame graphs — which highlight what changed between two captures — make this obvious.
Measure the metric that mattered
Tie validation back to the original symptom. If P99 latency was the problem, confirm P99 improved under representative load, not just that CPU dropped. It's common to reduce CPU while barely touching latency because the real constraint was a downstream dependency.
Load-test the change
Reproduce production-like traffic against a canary before full rollout. Tools like k6 or Locust let you replay realistic request mixes. Profile the canary under load to confirm the hot path is genuinely resolved and no new plateau appeared. This is exactly the kind of validation work Halkwinds builds into its engineering engagements — pairing a fix with load-tested proof rather than a hopeful deploy.
Watch for the shifted bottleneck
Optimization rarely eliminates a limit; it relocates it. Speed up serialization and you may saturate the network. Reduce lock contention and you may expose a database ceiling. Re-profile after every meaningful fix.
Actionable takeaway: Every performance fix ships with two artifacts — a before/after flame graph and a load-test result for the originating metric. No graph, no merge.
Common Mistakes / What to Avoid
- Profiling the wrong resource. Reaching for a CPU profiler on an I/O-bound service produces a clean flame graph and zero insight. Classify the bottleneck first.
- Using high-overhead instrumentation in production. Full method-level instrumentation can multiply latency. Prefer sampling profilers like py-spy, async-profiler, and perf for live syst
Explore Further