Profiling: measure before you optimise
Wednesday, 10:05: after release 4.12, the hotel quote's p95 jumps from 250 ms to 2 s. The servers sit at 20% CPU, and Karim is already proposing a three-month rewrite in Go.
Before you speed anything up, find out where the time really goes. Your first guess is often wrong.
- Problem
After a release, the hotel booking API's p95 latency jumps from 250 ms to 2 s. The team's first instinct: "Node is slow, let's rewrite the hot endpoint in Go."
- Why the simple system fails
Guessing optimises whatever is easiest to see. Here CPU sits at 20%: the servers aren't working hard, they are waiting. A faster language would change nothing.
- Engineering concept
Measure first. Decide whether time is spent working (CPU) or waiting (I/O, locks, queues), then zoom in with the tool that answers that question.
- Technology
APM and distributed traces to find the slow hop, CPU profilers and flame graphs for compute, EXPLAIN ANALYZE and query statistics for the database, load tests to reproduce problems at scale.
- Trade-offs
Every measurement has overhead and blind spots: sampling misses rare events, staging lies about production data, instrumenting everything costs money. Measure enough to decide, then stop.
The problem
2 min- Omar · On-call SRE
GET /hotels/:id/quote: p95 from 250 ms to 2,000 ms since 4.12. Pod CPU is 20%. - Karim · CTONode is too slow for this. Let's rewrite the quote endpoint in Go. About three months.
- Lina · Senior engineer, your mentorYou lead this one; I'll only ask questions. Your first guess?
The big idea
4 minMeasure before you optimise: is it working or waiting? Then find the biggest slice, and use the tool that looks inside it.
Karim's Go rewrite only shrinks our 230 ms of code: 2,010 ms becomes about 1,825. Three months for 9%. The index took ten minutes.
How it works
5 min- 1Pin the symptom
Which endpoint, which percentile, since when? Quote p95, since the 4.12 deploy at 21:30.
- 2Working or waiting?
CPU low → waiting: trace it. CPU near 100% → working: profile the code. Here, 20%: waiting.
- 3Open a slow trace
A trace is one request's timeline, split into spans, one per step. A 2,010 ms quote: auth 8, availability query 1,712, pricing code 230, render 60.
- 4Look inside the biggest span
EXPLAIN ANALYZE runs a query and shows how it was executed. Here: a full scan, 4 million rows read to return 58. 4.12 added it with no index.
- 5If it's working: a flame graph
A CPU profiler samples which functions run. Its flame graph draws them: the wider a box, the more CPU it used. Height is only call depth.
- 6Fix and re-measure
Index on
(hotel_id, night): the query takes ~4 ms. p95 back to ~240 ms, same traffic, same percentile.
Watch a decision being made
4 minYou profile search ranking
On Sunday evenings, search-ranking pods run at 85% CPU and p95 is 520 ms. Find where the CPU goes and fix it without adding pods.
- 1Confirm it's work, then profile under real load
A trace shows 450 ms of 520 inside
rankHotels, with no queries or calls. Record a 30 s profile on one production pod at peak.bashnode --cpu-prof --cpu-prof-dir=/tmp/profiles dist/server.jsWhy: Lina: "profile where the problem lives. A function that's cheap once can dominate at 2,000 requests a second."
- 2Find the widest box
formatPriceis 45% of CPU samples; under it, most width isnew Intl.NumberFormat.Why: Width is CPU time. A tall, thin tower is cheap.
- 3Name the cause, fix the cause
Each request formats ~400 prices and builds a new formatter for every one. Create one per currency and reuse it.
Why: Not "JavaScript is slow": one expensive object created 400 times per request. A one-line fix beats any rewrite.
- 4Profile again
CPU 85% → 48%, p95 520 → 310 ms. The new widest box is JSON serialisation, 18%.
Why: Re-measuring proves the fix and shows the next slice; stop when you meet the goal.
In real life
3 minGoogle: continuous profiling of the fleet
- Has published work on profiling its whole fleet continuously at low overhead
- Engineers can compare a service's profile with last week's
- Needs storage, tooling and a team to run it
Worth it when small CPU savings multiply over a huge fleet.
Netflix: flame graphs when investigating
- Its engineers popularised flame graphs in widely read public talks
- Profiles recorded on demand when a service misbehaves
- Cheap most of the time; the problem may be gone when you look
Worth it when traces and metrics tell you where to profile.
What goes wrong
3 minQuery plans depend on data size; 500 rows hide what 9 million reveal.
Test with production-sized data or statistics.
"Node is slow" leads to rewrites that save 9% of a problem an index fixes.
Measure first: working or waiting, then the biggest slice.
The fix may not have worked, or the bottleneck moved.
Same endpoint, same percentile, same load, before and after.
Trade-offs
3 min| Tracing policy | Monthly cost | Keeps | Misses |
|---|---|---|---|
| 100% of traces | ~€4,200 | Everything | Nothing, but mostly stores boring requests |
| 5% + every error and slow trace | ~€350 | Every trace you'd open in an incident | Rare-but-normal paths; needs a buffering collector |
- Omar · On-call SREI want 100% of traces. Wednesday, the one trace that mattered could have been dropped.
- Karim · CTO5% of 2,000 requests a second is still thousands of traces an hour.
- Karim · CTOKeep the rare ones. Don't keep the millions of boring ones.
Your job as tech lead
4 minClaude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.
- Nothing gets optimised before it's measured, least of all a ~3-month rewrite.
- The evidence a performance change needs: a trace showing the dominant span, a query plan or flame graph for it, and a before/after at the same percentile.
- What data tests run against: production-sized (9 million reviews), not a laptop with 500.
Since release 4.12, GET /hotels/:id/quote has p95 of about 2,000 ms instead of 250 ms, with pods at 20% CPU. Don't change code yet. Pull three slow traces and tell me which span dominates and by how much. If it's a query, run EXPLAIN ANALYZE on staging (production-sized data) and show the plan. Propose the smallest fix, such as an index, with the migration and how long it locks the table. After I approve, apply it in staging and rerun the same load test, comparing p95 before and after.
- The diagnosis cites span durations from a trace, not "probably the database".
- The plan ran on production-sized data: a
Seq Scanbefore, anIndex Scanafter. - Amdahl check: how much of the 2,010 ms can the change remove at most?
- It starts optimising or rewriting before showing where the time goes.
- It proposes a faster language while CPU is at 20%.
- It reports an average improving and calls it solved.
Check yourself
2 minNext episode: hotel pages send 3,000 identical reads a second to PostgreSQL, and its CPU sits at 90%.
Mark it complete to update your skill map.
Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Interactive simulation, Visual diagram, Analogy, Check your understanding, Practice ladder, Explain it yourself.
Technical deep dive
You lead the second investigation, on search ranking. This time the CPU is busy, so the tool changes. Lina adds one step.
You profile search ranking
On Sunday evenings, search-ranking pods run at 85% CPU and p95 is 520 ms. Find where the CPU goes and fix it without adding pods.
- 1Confirm it's work
CPU 85% at peak, and a trace shows 450 ms of the 520 ms inside the
rankHotelsspan with no child spans (no queries, no calls).Why: Busy CPU plus time inside our own code means working. A CPU profile will see it; a query plan won't.
- 2Profile under real load
Record a profile on one production pod during peak for 30 s (a sampling profiler adds little overhead).
bashnode --cpu-prof --cpu-prof-dir=/tmp/profiles dist/server.js # or attach to a running process with your platform's profilerWhy: Lina: "profile where the problem lives. A function that's cheap for one request can dominate at 2,000 requests a second."
- 3Find the widest box
In the flame graph,
formatPriceis 45% of CPU samples. Beneath it, most width is innew Intl.NumberFormat.Why: Width is CPU time. Height is only call depth: a tall, thin tower is cheap.
- 4Name the cause
Each ranking request formats ~400 prices and builds a new number formatter for every one. Building a formatter is expensive; using one is cheap.
Why: The cause picks the fix. It's not "JavaScript is slow"; it's one expensive object created 400 times per request.
- 5Fix the cause
Create one formatter per currency and reuse it (a small map). Ship behind no flag: it's a pure function with tests.
Why: A one-line change to the widest box beats any rewrite or extra pods.
- 6Profile again
Same load: CPU 85% → 48%, p95 520 → 310 ms. The new widest box is JSON serialisation of the response, 18%.
Why: Re-measuring proves the fix and shows the next target. The biggest slice always moves.
| Question | Tool | What it can't tell you |
|---|---|---|
| Which endpoint got slower, at which percentile? | Metrics / APM dashboards | Why it got slower |
| Which hop of the request is slow? | Distributed traces (spans) | Which line of code burns CPU inside a span |
| Which functions use the CPU? | Sampling CPU profiler, flame graph | Time spent waiting on I/O or locks |
| Why is this query slow? | EXPLAIN ANALYZE, slow-query log, pg_stat_statements | How it behaves under concurrent load |
| Where does it break as traffic grows? | Load test (k6, Gatling, Locust) | Real production data skew, unless you replay it |
Interactive simulation
Visual diagram
A booking API with APM tracing. Walk through a measurement-first investigation of a slow hotel quote.
Analogy
- A slow hotel check-inFollowing one guest with a stopwatchProfiling a requestA trace
- A slow hotel check-in15 of 20 minutes waiting for the key-card printerProfiling a requestTime spent waiting, not working
- A slow hotel check-inThe receptionist is idle most of the timeProfiling a requestLow CPU, high latency
- A slow hotel check-inFilming the receptionist's handsProfiling a requestA CPU profile or flame graph
- A slow hotel check-inSending a tour bus of 60 guestsProfiling a requestA load test
Hiring a faster receptionist changes nothing when the printer is the problem. Where the analogy breaks: in software, the "printer" is often invisible until you measure, because waiting doesn't show up as busy.
Check your understanding
Practice ladder
Which tool first?
Debugging Warm-upFor each situation, say whether the service is working or waiting, and which tool you'd open first.
- The payments API's p95 went from 300 ms to 1.4 s at 09:00. CPU 15%. The payment provider had a status-page incident at 08:55.
- The image-resize worker processes 40 images a minute instead of 120 after a library upgrade. CPU 100%.
- The owner dashboard is slow only for the 3 hotel groups with 300+ hotels. CPU normal.
- Search is fine all day, but every request is slow between 02:00 and 02:20. CPU normal on search pods.
Finish the investigation order
Debugging PracticeThe booking confirmation page's p95 doubled after yesterday's deploy. Lina's investigation has two blank steps. Fill them in.
- Pin the symptom: confirmation page, p95 from 400 to 800 ms, since the 17:00 deploy. p50 unchanged.
- Working or waiting? CPU unchanged at 30%.
- ___
- The slowest span in slow traces is
SELECT * FROM loyalty_points WHERE guest_id = …, new in this deploy, 380 ms. - ___
- Fix, then re-measure p50, p95 and p99 under the same traffic.
The invoice worker
Debugging PracticeThe invoice worker creates a PDF for each booking. Since Monday, the queue grows every evening. Data:
- Each worker pod: CPU 97%.
- A 30-second CPU profile on one pod shows:
renderPdf22%,loadFont58% (called insiderenderPdffor each invoice),zlib.deflate12%, other 8%. - Monday's release added two new fonts for Japanese and Korean invoices.
Karim wants to double the worker pods. Explain what's happening and propose a fix, with an expected result.
Load test before Black Friday?
Decision StretchBlack Friday is in four weeks. Last year StayHub's peak was 3× a normal Friday. Since then, search and checkout changed a lot (batched calls, new transaction logic). Options:
- A: a load test at 4× normal Friday traffic against a production-sized staging copy. About 5 days of work for 2 engineers, plus ~€800 of temporary infrastructure.
- B: no load test; add 50% more pods the week before and watch dashboards on the day.
Write a short decision: "We chose X over Y because…, accepting the risk of…".
Explain it yourself
A colleague says: "The API is slow, so let's rewrite it in a faster language." Explain how you would decide whether that could help.