Finding the slow code — the workflow that gets you from it feels slow to the exact line
Everyone knows the page is slow. Almost nobody knows why, and the usual response — open the code, find something ugly, rewrite it — has roughly the success rate of guessing. Performance work is not a coding skill, it is an investigative one: a fixed sequence of narrowing questions where each answer eliminates most of the codebase. Here is that sequence, end to end, plus the ten shapes almost every real bottleneck turns out to have, how to recognise each one, and what the fix costs.
The issue
The ticket says the dashboard is slow. You open the dashboard. It takes about three seconds, which is indeed slow. You open the code, and within a minute you find something that offends you: a .filter() feeding a .map() feeding a .sort() — three passes where one would do. You rewrite it as a single loop. It is genuinely better code. You ship it. The dashboard still takes about three seconds, because those three passes were running over 80 rows and took four milliseconds together. You have spent an afternoon making the fastest part of the page slightly faster.
This happens to everyone, and it happens because of one wrong instinct: that slow code should look slow. It almost never does. The line costing you two seconds is usually short and perfectly reasonable — a property access that an ORM quietly turns into a database round trip, an await sitting inside a loop, a lookup in an array that happens to be inside another loop. Slowness is a property of how often something runs and what it waits for, and neither of those is visible in the code you are reading.
So performance work is not really a coding skill. It is an investigative one: a fixed sequence of narrowing questions where every answer eliminates most of the codebase from suspicion. Six steps, and five of them are measurement. This issue is that sequence — first the workflow, then a catalogue of the ten shapes almost every real bottleneck turns out to have, with how to spot each one and what the fix actually costs.
Rule zero: you are not allowed to guess
The most quoted line in this whole field is Donald Knuth's premature optimization is the root of all evil, and it is quoted almost entirely by people who have chopped off the end of the sentence. The full passage says we should forget about small efficiencies about 97% of the time — yet we should not pass up our opportunities in that critical 3%. The instruction was never "don't optimise". It was: find the 3% first, with a measurement, because you will not find it by reading.
One more framing before the steps, because it saves arguments later: there are two different quantities people call performance. Latency is how long one thing takes. Throughput is how many things you can do per second. They are not the same and they are frequently traded against each other — batching improves throughput and worsens latency; a bigger worker pool improves throughput right until the point where everything queues. When someone says "make it faster", find out which one they mean.
Step 1 — Turn "slow" into a number
"Slow" is a feeling, and you cannot verify a fix against a feeling. Before anything else, convert it: which action, on whose data, measured how, at which percentile, and what number counts as fixed. "The orders page takes 3.4 s at p95 for accounts with more than 5,000 orders, and we want it under 1 s" is a task. "The dashboard is slow" is a mood.
That figure is the single most useful thing to internalise about measurement. Line up every request from fastest to slowest; the p95 is the one at the 95% mark, and it means one request in twenty was worse than that. A user who loads twenty pages in a session meets their p95 about once. That is not an edge case, that is Tuesday. The average, meanwhile, gets dragged up by the tail and down by the fast majority and ends up representing neither group. Averages are for dashboards nobody acts on; percentiles are for deciding what to fix.
// Collect durations for one action. Then look at the ones that hurt.
function percentile(samples, p) {
const sorted = [...samples].sort((a, b) => a - b);
const index = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, index)];
}
const ms = [120, 138, 141, 150, 160, 168, 175, 190, 210, 240,
260, 280, 310, 340, 380, 420, 520, 900, 1800, 2400];
console.log("average", ms.reduce((a, b) => a + b, 0) / ms.length); // 385.1
console.log("p50 ", percentile(ms, 50)); // 240 — the typical load
console.log("p95 ", percentile(ms, 95)); // 1800 — where the tickets come from
console.log("p99 ", percentile(ms, 99)); // 2400 — where the angry ones come from
// The average sits above 60% of these samples and below the two that matter.
// Twenty samples is a toy — use a few thousand before you trust p99.And the human thresholds behind those numbers, which come from Jakob Nielsen's work and hold up remarkably well: about 100 ms feels instantaneous — the user believes they caused it directly. About 1 second keeps them in flow; they notice the delay but do not lose their train of thought. Around 10 seconds you have lost their attention and they switch tabs. Those three numbers are why 300 ms and 800 ms feel like the same speed to a user, while 800 ms and 3 s feel like different products.
Step 2 — Reproduce it before you touch anything
If you cannot make the slowness happen on demand, you cannot tell a fix from a coincidence. This step sounds like bureaucracy and it is the one that most often produces the answer by itself, because writing down the exact conditions forces you to notice what is different about them.
The most valuable line in that list is the second one. It is almost always someone else's data. You have forty rows locally; the account that filed the ticket has four hundred thousand. Code that is quietly quadratic, a query with no index, an endpoint that returns every record ever created — all three are invisible at forty rows and fatal at four hundred thousand. Seed a realistic dataset locally and a surprising share of these bugs diagnose themselves before you have opened a profiler.
Step 3 — Find the slow layer before you find the slow line
This is the highest-leverage step in the whole workflow, and it is the one people skip. Before you look at any code, split the elapsed time into bands and find out which band owns it. A request has roughly five: the network, your server code, the data store, the payload coming back, and the browser's own work turning that payload into pixels.
You do not need a fancy tool for this. On the client, the DevTools Network panel already breaks every request into queueing, waiting (that is TTFB) and content download — and if the waiting bar is 1.9 s, no amount of React optimisation will help you, because the server had not begun to reply. On the server, twenty minutes of hand-placed timers will do:
async function ordersHandler(req, res) {
const t = { start: performance.now() };
const mark = (name) => (t[name] = performance.now());
const user = await auth(req); mark("auth");
const orders = await loadOrders(user); mark("db");
const view = renderOrders(orders); mark("render");
// One structured line per request. Log it, then aggregate it.
console.log(JSON.stringify({
route: "/orders",
userId: user.id,
rows: orders.length,
auth_ms: Math.round(t.auth - t.start),
db_ms: Math.round(t.db - t.auth),
render_ms: Math.round(t.render - t.db),
total_ms: Math.round(t.render - t.start),
}));
// Standard header — the browser shows these bands in the Network panel,
// right next to its own timings, for free.
res.setHeader("Server-Timing",
"db;dur=" + Math.round(t.db - t.auth) +
", render;dur=" + Math.round(t.render - t.db));
res.send(view);
}Log the row count alongside the timings. That single extra field turns your logs into a scatter plot: if db_ms climbs in a straight line with rows, you have a per-row cost — an N+1 or a missing index. If it is flat regardless of rows, you have a fixed cost — a connection setup, a lock, a cold cache. Those are two completely different investigations, and one logged number tells you which one you are in.
Step 4 — Open the profiler, and learn to read it properly
Once you know the layer, you profile inside it. Every profiler in every language eventually shows you the same picture — a flame chart — and being fluent in that one picture is most of the skill.
The top bar is the whole operation. Each row below it is what that row's parent called, and each box's width is proportional to the time spent inside it. Boxes never get wider as you go down, only narrower — a child cannot take longer than its parent. So you walk down the widest path until the width stops shrinking, and that is where the time is. Everything above that box is just the call path that got you there, and starting to optimise one of those middle rows because its box looked wide too is a very common way to waste a day.
Step 5 — Name the shape
Here is the good news nobody tells beginners: there are not a thousand kinds of performance bug. In application code there are about ten. They recur across every language and every stack, and once you can recognise them by their symptoms, diagnosis turns into pattern-matching. What follows is each shape — how it announces itself, how to confirm it, and what the fix costs, because every fix costs something.
Shape 1 — The N+1: a query inside a loop
This is the most common server-side performance bug in the industry, and ORMs make it invisible. You fetch 200 orders, then loop over them to show each customer's name, and the innocent-looking order.customer.name fires a separate query per order. One query becomes 201. Each is fast — 8 ms, nothing — and 201 × 8 ms is 1.6 seconds on a page that should have taken 20.
// SLOW — 1 query for the orders, then 1 more per order. 201 round trips.
const orders = await db.orders.findMany({ where: { userId } });
for (const order of orders) {
order.customer = await db.customers.findUnique({ where: { id: order.customerId } });
}
// FAST — 2 round trips, whatever the number of orders.
const orders = await db.orders.findMany({ where: { userId } });
const ids = [...new Set(orders.map((o) => o.customerId))];
const customers = await db.customers.findMany({ where: { id: { in: ids } } });
// Index the result once, then each order is a hash lookup, not a network hop.
const byId = new Map(customers.map((c) => [c.id, c]));
for (const order of orders) order.customer = byId.get(order.customerId);Shape 2 — The waterfall: doing things one at a time for no reason
Three independent calls that each take 300 ms take 900 ms if you await them in sequence and 300 ms if you do not. In a profile this shows up as a staircase: every bar starting exactly where the previous one ended, nothing overlapping. It is one of the easiest wins in the entire discipline.
// SLOW — 900 ms, and none of these three needs the others.
const profile = await getProfile(userId); // 300 ms
const orders = await getOrders(userId); // 300 ms
const credits = await getCredits(userId); // 300 ms
// FAST — 300 ms. They start together and you wait for the slowest.
const [profile, orders, credits] = await Promise.all([
getProfile(userId),
getOrders(userId),
getCredits(userId),
]);
// When one genuinely needs the result of another, the dependency is real
// and the await stays. Parallelise what is independent, not what is not.
// And do not fan out without a limit: 5,000 parallel queries will take your
// database down more effectively than the slow version ever did.
async function mapWithLimit(items, limit, fn) {
const out = [];
for (let i = 0; i < items.length; i += limit) {
out.push(...(await Promise.all(items.slice(i, i + limit).map(fn))));
}
return out;
}The same shape appears on the client as a request waterfall: the HTML loads, which loads the JS, which runs and discovers it needs the user, which returns an id the next call needs. Four sequential round trips before anything renders. The fixes are the same idea at a different altitude — start the fetch earlier (preload, prefetch), collapse the chain on the server so one response carries everything, or stream the parts of the page that are ready instead of holding all of it hostage to the slowest one.
Shape 3 — The missing index: the database reading the whole table
A database with no index for your query does the only thing it can: it reads every row and throws away the ones that do not match. At 500 rows that is instant, which is why it passed review. At 5 million it is a table scan and your query is measured in seconds. The tool that tells you is EXPLAIN, and reading its output is a genuinely load-bearing skill.
EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 20;
-- BEFORE — no usable index
-- Seq Scan on orders (cost=0.00..184203.00 rows=31 width=284)
-- Filter: (customer_id = 42)
-- Rows Removed by Filter: 4999969 <-- read 5M rows to return 31
-- Execution Time: 2143.882 ms
CREATE INDEX CONCURRENTLY orders_customer_created_idx
ON orders (customer_id, created_at DESC);
-- AFTER
-- Index Scan using orders_customer_created_idx on orders
-- (cost=0.43..8.91 rows=31 width=284)
-- Index Cond: (customer_id = 42)
-- Execution Time: 0.214 ms <-- four orders of magnitude, one line of DDLShape 4 — The accidental O(n²)
Nobody writes a nested loop on purpose. What they write is a .find() or an .includes() inside a loop, which is a nested loop wearing a nice coat. Each of those scans the whole array; do it once per item and you have n × n work. This is the bug that passes every test, sails through review, works fine for a year, and then takes down the account of your largest customer.
// SLOW — find() walks the whole customers array for every single order.
// 10,000 orders x 10,000 customers = 100,000,000 comparisons.
const rows = orders.map((order) => ({
...order,
customer: customers.find((c) => c.id === order.customerId),
}));
// FAST — build the lookup once, then every read is O(1).
// 10,000 + 10,000 = 20,000 operations.
const byId = new Map(customers.map((c) => [c.id, c]));
const rows = orders.map((order) => ({
...order,
customer: byId.get(order.customerId),
}));
// Same family, same fix:
// arr.includes(x) inside a loop -> new Set(arr).has(x)
// arr.indexOf(x) inside a loop -> a Map from value to index
// arr.filter(...) inside a loop -> group once into Map<key, item[]>
// a .sort() inside a loop -> sort once, outside itThe identification trick is to stop reading and start counting: multiply the collection size by the loop count and see whether the product is in the millions. And the practical test is even simpler — run it against ten times the data and watch whether the time goes up ten times or a hundred. That one experiment separates linear from quadratic in about a minute, without a profiler.
Shape 5 — Doing the same work over and over
A surprising amount of software recomputes identical answers all day. The same config parsed on every request, the same regex compiled inside a loop, the same expensive aggregate recalculated for every visitor, the same API called four times in one render because four components each wanted it. The fix is caching — which is a ladder rather than a single technique, and each rung up is faster and harder to invalidate.
Shape 6 — Sending far more bytes than anyone needs
The fastest code is code that never had to travel. This shape is the least clever and probably the most common on the client: an endpoint returning 4,000 records because nobody added pagination, a JSON payload with forty fields per row when the table shows four, a 2 MB hero image displayed at 400 px wide, a bundle that ships an entire date library to format one timestamp.
Shape 7 — Blocking the one thread the user can see
A browser tab runs your JavaScript, its layout calculations and its painting on a single main thread, taking turns. Occupy that thread for 300 ms and the page is frozen for 300 ms: clicks do nothing, animations stop, the cursor does not change. Nothing has crashed, but the app feels broken — and "feels broken" is precisely what INP measures.
// SLOW — every read after a write forces the browser to recompute layout
// synchronously. 200 items becomes 200 forced reflows.
for (const el of items) {
const width = el.offsetWidth; // READ — needs fresh layout
el.style.width = width + 20 + "px"; // WRITE — invalidates layout again
}
// FAST — batch all the reads, then all the writes. One layout pass.
const widths = items.map((el) => el.offsetWidth); // all reads
items.forEach((el, i) => { // all writes
el.style.width = widths[i] + 20 + "px";
});
// Anything that returns geometry forces layout when read after a write:
// offsetTop, clientHeight, getBoundingClientRect, getComputedStyle,
// scrollTop. In DevTools they appear as purple "Layout" bars, and a long
// row of them is the signature of this bug.Two of those have their own issues in this series: how React decides what to re-render is in Issue 011 and Issue 010, and moving real work off the main thread with a pool of workers is Issue 003.
Shape 8 — The queue: latency explodes while the CPU is bored
This one is different in kind from the others, and it is the most often misdiagnosed. The symptom: at 50 requests a second everything responds in 80 ms; at 120 requests a second responses take four seconds — and the CPU is at 30%, memory is fine, and the queries are the same queries. Nothing got slower. Things started waiting, because some resource has a fixed number of slots and they are all occupied.
Shape 9 — Memory, garbage, and the leak that looks like slowness
If a process is fine after a deploy and sluggish six hours later, and a restart cures it, you are not looking at slow code. You are looking at memory. A managed runtime pauses to collect garbage, and the more live objects it has to walk, the longer and more frequent those pauses get. Long enough, and every request pays a share of them.
Shape 10 — The part you do not own
Sometimes the profile is clean, the queries are indexed, the bundle is small, and the page still takes four seconds — because of a chat widget, an analytics tag, an A/B testing script that blocks rendering by design, a payment provider having a bad afternoon, or a serverless function cold-starting because nobody has called it in fifteen minutes. You cannot make that code faster. You can decide how much of your page it is allowed to hold hostage.
Step 6 — Fix one thing, and make it the biggest thing
You will usually find three or four things wrong. Fix them one at a time, largest first, re-measuring after each — and be honest about what the largest one buys you, because there is a hard ceiling and it is arithmetic, not effort.
Make 5% of the work ten times faster and the whole thing gets 4.5% faster — imperceptible, for an afternoon of work and a diff somebody has to review. Make the 70% merely twice as fast and you are 35% faster. This is why step 3 matters so much: picking the right slice is worth more than every micro-optimisation you know. The corollary is uncomfortable and true — once the dominant cost is fixed, the remaining ideas on your list are usually not worth doing, and stopping is the correct engineering decision.
One change per measurement. Ship two fixes together and you know the total moved but not which half moved it — and if one of them made things worse, that damage is now hidden inside the other one's win.
Step 7 — Verify, then make it hard to break again
Re-run the exact measurement from step 1, on the exact scenario from step 2, with the same tool, at the same percentile. If the number did not move, revert the change — a change that does not help is not neutral, it is code somebody has to understand forever.
The order to reach for fixes
When you know where the time is going, the options come in a rough order of cheapness — and it is almost exactly the reverse of what engineers reach for first.
For product managers and founders
You do not need to read a flame chart. You do need to tell a real performance plan from an expensive one, and that comes down to a handful of questions and one piece of arithmetic.
The arithmetic first: speed is worth money, but the famous numbers are older and shakier than they sound. Amazon found 100 ms cost 1% of sales is a 2006 anecdote from a conference talk; Google found 500 ms cost 20% of traffic is from the same era. Both are directionally right and neither is a law of nature. The honest move is to measure it on your own funnel: bucket sessions by page-load time and compare conversion across the buckets. If your slowest quartile converts materially worse and nothing else explains it, you have a business case in your numbers, for your product, instead of somebody else's slide from twenty years ago.
Two more things worth knowing. First, when not to do this: with a hundred users and no product-market fit, a bigger server is far cheaper than an engineering week, and the slow page is not the reason people are not signing up. Performance is a feature and it competes with other features on the same roadmap. It becomes urgent when it costs you conversion, when it costs real money in infrastructure at scale, or when it is bad enough to become the thing people say about your product.
Second, perceived performance is real performance — up to a point. A skeleton screen, an optimistic update that assumes the save worked, a progress bar that actually moves, streaming the page in as it becomes ready: these change how long the wait feels without changing a millisecond, and they are often a tenth of the work. They are a legitimate first response. They are not a substitute — no spinner makes a ten-second wait acceptable, and an optimistic update that turns out to be wrong is worse than the wait was. Use them to buy time, then go and fix the number.
The whole thing on one page
What this actually is
Strip out the tooling and performance work is one discipline repeated at every scale: refuse to guess, split the time in half, and open only the half that is heavy. That is what a flame chart does to a function, what a query plan does to a statement, what a trace does to a distributed system, and what a request waterfall does to a page load. Four pictures, one move — and the move works whether you are ten minutes into your first profile or ten years into your career.
The reason this matters more than knowing a hundred optimisation tricks is that the tricks are worthless without the aim. Every engineer has a favourite micro-optimisation; very few can tell you, for the page they shipped last week, where the time is going. The one who can will beat the one who cannot every single time, using duller tools. Start with the number. The number tells you where to look, the shape tells you what to do, and the ceiling tells you when to stop.
If you want one thing to do tomorrow: pick the page in your product that people complain about most, open DevTools, and find out what fraction of its time is spent before the first byte arrives. You will know more about it in ninety seconds than the last six months of speculation produced — and whichever way that number falls, half of what you were worried about just stopped being your problem.