322 backend latency profiling
Skill FluxonLab/Skillry/plugins/performance-and-cost/skills/322-backend-latency-profiling
Installable, permission-bounded, multi-platform agent skills & subagents for Claude Code, Codex, Copilot & Gemini/Antigravity — 125 skills + 73 subagents across 18 departments, with a validation harness, native plugin marketplace, and full upstream attribution. by FluxonLab.
npx -y skills add FluxonLab/Skillry --skill 322-backend-latency-profilingAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Use when you need to profile backend request latency — measuring p50/p95/p99 (not averages), capturing flame graphs, finding slow code paths with py-spy/0x/pprof, and diagnosing connection-pool exhaustion and async/event-loop bottlenecks.
SKILL.md
11.0 KB, as published. Nobody here has run it
Backend Latency Profiling
Purpose
Find and explain why backend requests are slow, using tail-latency percentiles and CPU/off-CPU profiling rather than guesswork. Averages lie; this skill anchors on p50/p95/p99 and on flame graphs that show where wall-clock and CPU time actually go. It covers sampling profilers (py-spy, 0x, pprof), connection-pool and thread-pool exhaustion, and async/event-loop blocking. The output is a profiled hot path with a measured before/after, not a vague "optimize the database" note.
When to use
- p95 or p99 latency is high while p50 looks fine (a tail-latency problem).
- A specific endpoint is slow and you need to know where the time goes (CPU, I/O wait, lock contention, GC).
- Throughput collapses under load and you suspect connection-pool or thread-pool exhaustion.
- p99 is spiky and intermittent and you need to tell GC/host pauses apart from genuinely slow code.
- An async service (Node, asyncio, Go) stalls and you suspect the event loop is blocked by sync work.
- A latency regression appeared after a deploy and you need a flame-graph diff.
When not to use
- The bottleneck is purely a single slow SQL query already identified by the database's slow-query log — go straight to query optimization.
- The problem is frontend render time, not server response time — use the frontend performance budget skill.
- The slowdown is a one-off caused by a known batch job or deploy that has already finished, not a steady-state regression.
- You have no way to reproduce load or attach a profiler in any environment (fix observability first; you cannot profile what you cannot run).
Procedure
- Quantify the tail, not the mean. Pull latency as a distribution and compute p50/p95/p99 per endpoint. A 50 ms average with a 4 s p99 is a tail problem affecting real users; the average alone hides it. Anchor targets to the SLO percentile (usually p95 or p99), because that is what users at the edge feel.
- Reproduce under representative load. Use a load generator at realistic concurrency. Tail latency only appears under contention; a single curl will not surface pool exhaustion or queueing. Warm the service first so JIT/cache effects do not pollute the first samples.
- Attach a sampling profiler and capture a flame graph. Sampling profilers (py-spy, 0x, pprof, async-profiler) add low overhead and can often attach to a running process. Capture both on-CPU and, where supported, off-CPU (waiting) time — off-CPU is where I/O and lock waits hide.
- Read the flame graph top-down for width. Width = time. The widest frames are where time is spent. Distinguish CPU-bound (your code is wide) from I/O-bound (you see wait/poll frames) from lock contention (futex/mutex frames). Ignore stack height; depth is structure, not cost.
- Check pools and queues. Inspect database connection-pool size vs. concurrency, HTTP client pool limits, and thread/worker counts. Exhaustion shows as requests queueing for a connection while CPU is idle — a tell-tale flat-CPU/high-latency signature. Size the pool to the dependency's capacity, not just request concurrency.
- Find event-loop / async blocking. In async runtimes, look for synchronous CPU work, blocking I/O, or large JSON (de)serialization on the loop. A single blocking call stalls every concurrent request sharing that loop. Move it to a worker pool, stream it, or make it non-blocking.
- Trace the dependency chain. A slow endpoint is often slow because one downstream call (DB, cache, third-party) is slow under load. Use distributed tracing or per-span timing to find which hop owns the latency before optimizing your own code.
- Separate pauses from work. A spiky p99 can be GC pauses, stop-the-world compaction, or a noisy neighbor rather than slow code. Correlate the latency timeline with GC logs and host CPU steal; if the tail aligns with pauses, tune the collector or isolate the workload instead of rewriting the handler.
- Fix one hot path, then re-profile. Apply a single change, re-run the same load, and compare percentiles and flame graphs. Keep the before/after artifacts so the improvement is provable and reviewable.
Concrete checks
- Latency is reported as p50/p95/p99 per endpoint, not as an average.
- Measurements were taken under representative concurrent load, not a single request.
- A flame graph (or equivalent profile) was captured and the widest frame identified.
- The bottleneck is classified: CPU-bound, I/O-wait, lock contention, GC, or queueing.
- Connection-pool max size is compared against peak concurrency; no silent queueing for connections.
- No synchronous/blocking call runs on an async event loop's main thread.
- Timeouts and retries are bounded so a slow dependency cannot pile up unbounded latency.
- Every outbound dependency call has an explicit timeout (no unbounded waits holding workers).
- N+1 query patterns and per-request serialization costs are ruled in or out.
- The slow downstream hop (DB/cache/third-party) is identified via tracing or per-span timing before local code is tuned.
- The service was warmed before measurement so cold-start/JIT effects do not skew the baseline.
- The p99 tail is checked against GC-pause and host CPU-steal timelines to rule out pauses vs. slow code.
- A before/after percentile comparison exists for any applied fix.
Commands or Templates
# --- Python: py-spy, attach to a running process, no code change ---
pip install py-spy
# Live top-style view of where CPU time goes
py-spy top --pid <PID>
# Record a flame graph for 30 seconds (SVG)
py-spy record --pid <PID> --duration 30 --output profile.svg
# Include time spent waiting (off-CPU), useful for I/O-bound services
py-spy record --pid <PID> --idle --duration 30 --output profile-idle.svg
# --- Node.js: 0x flame graph and clinic for event-loop diagnosis ---
npx 0x -- node server.js # generates an interactive flame graph
npx clinic doctor -- node server.js # flags event-loop blocking & GC
npx clinic flame -- node server.js # CPU flame graph
# --- Go: pprof CPU profile (with net/http/pprof imported) ---
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/profile?seconds=30
# --- Drive representative load to surface the tail (any HTTP service) ---
# hey: 200 concurrent, 20k requests
npx hey -n 20000 -c 200 https://example.com/api/endpoint
# wrk with a latency distribution
wrk -t4 -c200 -d30s --latency https://example.com/api/endpoint
# vegeta with explicit percentile report
echo "GET https://example.com/api/endpoint" | \
vegeta attack -duration=30s -rate=300 | vegeta report -type='hdrplot'
-- Postgres: per-statement tail latency from pg_stat_statements
SELECT substr(query,1,80) AS query,
calls,
round(mean_exec_time::numeric,2) AS mean_ms,
round((max_exec_time)::numeric,2) AS max_ms,
round(total_exec_time::numeric,2) AS total_ms
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 15;
# --- Compute true percentiles from a latency log (one ms value per line) ---
# Avoids the "average lies" trap by sorting and indexing the distribution.
sort -n latencies_ms.txt | awk '
{ v[NR]=$1 }
END {
function pct(p){ return v[int((p/100.0)*NR + 0.5)] }
printf "count=%d p50=%d p95=%d p99=%d max=%d\n", NR, pct(50), pct(95), pct(99), v[NR]
}'
# --- Java/JVM: low-overhead sampling flame graph with async-profiler ---
# -e wall captures off-CPU (I/O/lock) waits too, not just on-CPU time.
asprof -d 30 -e wall -f flame.html <PID>
Common issues & anti-patterns
- Optimizing the average. Tuning p50 while p99 stays at seconds leaves the worst user experiences untouched; SLOs live at the tail.
- Profiling a single request. Pool exhaustion, queueing, and lock contention only appear under concurrency; profile under load.
- Reading a flame graph for height instead of width. Stack depth is not cost; frame width (time) is.
- Undersized connection pool. A pool smaller than peak concurrency turns into a hidden serialization point; CPU sits idle while requests wait.
- Oversized pool. Too many connections can overwhelm the database; size to the database's capacity, not to request concurrency alone.
- Blocking the event loop. A synchronous hash, regex, or large
JSON.parseon the async loop stalls every concurrent request. - Unbounded retries. Retrying a slow dependency without a budget multiplies tail latency and can cause a retry storm.
- No timeout on a downstream call. A hung dependency with no timeout ties up a worker/connection indefinitely, turning one slow hop into total saturation.
- Cold-start samples in the baseline. Measuring before the JIT warms, caches fill, and pools open reports artificially high latency that does not reflect steady state.
- Synchronous logging on the hot path. Blocking, unbuffered logging (or DNS lookups) inside request handling adds latency that the flame graph will show as wide I/O frames.
- No before/after. Claiming a fix worked without re-profiling under the same load is unverified.
Required output
Produce a report containing:
- Latency table — p50/p95/p99 per affected endpoint, baseline values, and the SLO if one exists.
- Load context — the concurrency/rate used to reproduce, and the tool/command.
- Flame-graph finding — the widest frame(s), the path through the code, and the bottleneck classification (CPU / I/O / lock / GC / queue).
- Pool & loop status — pool sizes vs. concurrency; any blocking on the async loop.
- Fix + verification — the change applied and a before/after percentile and flame-graph comparison.
- Pause vs. work — whether the tail is dominated by GC/host pauses or by actual code execution, with the supporting correlation.
- Next safe action — the next hottest path or the safest config change (e.g., pool sizing) to try.
Safety
- Sampling profilers are low-overhead but not free; prefer staging, and on production attach for a bounded duration with a teammate aware.
- Do not run load generators against production; use staging or a load environment to avoid a self-inflicted outage.
- Never log request bodies or headers that may contain secrets or PII while profiling.
- Bound any profiling capture with
--duration/seconds=so it cannot run away. - Do not change connection-pool or timeout settings on production without approval and a rollback plan; pool changes can cascade.
- Remove or gate debug profiling endpoints (e.g.,
/debug/pprof) so they are not publicly exposed. - Store profiling artifacts (flame graphs, traces) outside the repo and outside any public report; they can embed file paths, hostnames, and query fragments.
- Coordinate load tests with on-call and rate-limit them; an aggressive run against shared staging can disrupt other teams.