How to Debug Performance Issues by Measuring Before Guessing
Performance work goes wrong in a predictable way: someone has a hypothesis, spends two days optimizing, and the page is still slow because the bottleneck was somewhere else entirely. Intuition about performance is unreliable, including experienced intuition.
The discipline is narrow. Measure first, find the largest contributor, fix that one thing, measure again. And measure the right statistic — averages hide the problem, because the requests that make users leave are in the tail, not the middle.
Find the bottleneck before touching anything
Every hour spent optimizing something that isn't the bottleneck is wasted, and it adds complexity you'll maintain forever. Establish where the time goes first.
- ›Look at p95 and p99, never the mean. An average of 200ms can hide 5% of requests taking eight seconds, and those are the ones people notice.
- ›Determine the layer first: browser, network, application, or database. Optimizing the wrong layer is the most common waste.
- ›For anything database-backed, check query count before query speed. N+1 is the single most common cause, and it's a hundred fast queries rather than one slow one.
- ›Reproduce with production-shaped data. Everything is fast on a thousand rows, and the plan the database chooses changes with volume.
- ›Fix one thing, measure again. Batched changes make it impossible to know what helped.
The approaches
Database: query plans and the N+1 problem
// best for: The first place to look in almost any slow backend.
- PostgreSQL
- MySQL
- MongoDB
- Django
- Ruby on Rails
- Laravel
- Supabase
The overwhelmingly common cause of a slow endpoint is not one slow query but hundreds of fast ones. An ORM loads fifty records, then loads each record's author individually inside the loop — fifty-one queries where two would do. Each takes two milliseconds and nobody notices in development with three rows.
Count queries per request before optimizing any individual one. Django's `django-debug-toolbar`, Rails' log, and Prisma's query events all surface this immediately, and the fix is eager loading: `select_related`, `includes`, `with`, or `include`.
When a single query is genuinely slow, `EXPLAIN ANALYZE` tells you why. The thing to look for is `Seq Scan` on a large table where you expected an index, which usually means the index doesn't exist, or the query is written in a way that can't use it — a function applied to the column, or a leading wildcard in a `LIKE`.
-- Look for: Seq Scan on a large table, rows estimate far from
-- actual (stale statistics), or a nested loop over many rows.
explain (analyze, buffers)
select * from orders where customer_id = 42 order by created_at desc limit 20;
-- Wrapping the column in a function makes the index unusable:
where date(created_at) = '2026-08-27' -- Seq Scan
where created_at >= '2026-08-27' -- Index Scan
and created_at < '2026-08-28'The gotcha
Applying a function to an indexed column silently disables the index. `where lower(email) = ?` cannot use a plain index on `email` — it needs an expression index on `lower(email)`. The query still returns correct results, just via a full scan, so it only shows up as slowness at volume.
Application profiling and APM
// best for: Working out which layer the time is actually going to.
- Sentry
- AWS
- Google Cloud
- Express.js
- Django
- FastAPI
- NestJS
Application performance monitoring instruments requests and produces a trace — a breakdown of where the time went inside a single request. That immediately answers the question intuition gets wrong: is this the database, an external API call, or your own code?
External HTTP calls are a frequent surprise. An endpoint that looks like a database problem often turns out to be blocking on a third-party API with no timeout set, which is also why a vendor's slow day becomes your outage.
For CPU-bound code, a sampling profiler produces a flame graph showing which functions actually consume time. Node has `--prof` and Clinic; Python has py-spy, which can attach to a running production process without restarting it.
Instrument production, not just local. Production has real data volumes, cold caches, and concurrency, and those are exactly the conditions that produce the p99.
The gotcha
An `await` inside a loop serializes calls that could run concurrently. Ten sequential 100ms API calls take a second; `Promise.all` takes 100ms. This reads as perfectly normal code and is one of the most common self-inflicted latency problems.
Frontend: Core Web Vitals and bundle analysis
// best for: Slow page loads, sluggish interaction, and anything affecting search ranking.
- React
- Next.js
- Vue.js
- Svelte
- Vercel
- Cloudflare
Start with the three metrics Google actually measures: LCP (when the main content appears), INP (how fast the page responds to interaction), and CLS (how much things move around). Lighthouse gives you a lab measurement; field data from real users is what counts, because your laptop is not a mid-range phone on cellular.
The usual LCP culprits are an oversized hero image, render-blocking resources in the head, and a slow server response. The usual INP culprit is too much JavaScript executing on the main thread — which is a bundle problem, not an algorithm problem.
Bundle analysis almost always finds something specific: a date library imported wholesale for one function, an icon set imported as a namespace, or a route-level component pulled into the initial chunk because of an eager import. Code splitting on route boundaries is usually the highest-leverage single change available.
The gotcha
Lighthouse scores on a development machine are meaningless — no throttling, warm cache, localhost latency. Test with mobile CPU throttling and a slow network profile, or use field data from real users. The gap between lab and field is routinely a factor of five.
Common pitfalls
Optimizing on averages
The mean hides the tail. A p50 of 100ms with a p99 of 6 seconds means one request in a hundred is unusable, and those users are disproportionately your heaviest ones — the ones with the most data. Always look at percentiles.
Testing with development-sized data
A hundred rows makes every query fast and every plan look fine. Postgres chooses different plans at different table sizes, so a query that scans is fine on 100 rows and fatal on 10 million. Test against production-shaped volume.
Adding a cache to hide a slow query
Caching an unindexed query means the first request after every eviction is still slow, and you've added invalidation complexity on top. Fix the query, then cache if it's still worth it.
Changing several things at once
Three optimizations shipped together and latency improved 30% — which one did it? Possibly one helped 40% and another hurt 10%. Ship and measure individually, or you're guessing about your own fixes.
Questions people actually ask
Where should I start looking?
Query count per request. N+1 queries are the most common backend performance problem by a wide margin, and they're invisible in development because each individual query is fast.
Why is it fast locally and slow in production?
Data volume, network latency between services, cold caches, and concurrency. Localhost has none of these. A query plan that uses an index on 1,000 rows can switch to a sequential scan at 10 million.
How do I know if an index will help?
Run `EXPLAIN ANALYZE` before and after adding it in a staging copy with realistic data. Indexes cost write performance and storage, so add them for measured problems rather than speculatively on every column.