How to Review Code for Performance With AI Without Guessing the Bottleneck
Ask AI to "review this for performance" and you get a tidy list of smells with no idea which one production actually spends time on. Here is how to review code for performance with AI so each finding is tied to a workload, ranked by evidence, and marked with the measurement that would confirm it.
Build a Performance Review PromptThe review that names ten suspects and convicts none
Paste a function into an AI and ask it to "review this for performance," and you get back a competent-looking list: this loop is O(n²), this allocates inside the hot path, this could be cached, this query looks like an N+1. Every item is plausible. Several are probably real. And not one of them tells you the thing you actually need — which of them your service spends its time on, and whether any of it matters at the sizes you really run.
That is the trap of a performance review done by reading alone, and an AI does not escape it, because it is reading the same static text you are. A list of smells is not a review. It becomes a review when each finding is tied to a path that actually runs, an input size that actually occurs, and a measurement that would prove the finding is the one worth fixing — or quietly retire it when the numbers say it is not.
The good news is that reading is genuinely useful for the half of the job it can do: spotting the patterns that get expensive as work grows. The discipline is in the other half — refusing to promote a pattern to a priority, or a hunch to a number, without the evidence that only running the code can supply.
What a static read can and cannot know
Reading code shows you shape: how many times a thing runs relative to its input, where work is repeated, where a call crosses a boundary into a database or a network. Those are real signals and they are worth trusting as far as they go. What reading cannot show you is cost. It does not know your real hotspot, how often the endpoint is actually called, how many rows come back, how long a query takes against your data, or how the time splits across CPU, memory, and the network. None of that is in the source.
So keep the boundary in front of you the whole way. The AI works only from the code, the execution context, and the measurements you give it. It connects to no repository and no profiler; no code is executed, no benchmark or load test is run, and it reads no query plan, no production metric, and no trace. It does not confirm the real bottleneck, and a change it suggests is a candidate, not a proven speedup. The tool here builds the review prompt in your browser and you run it in your own assistant; the profiling, the benchmarks, and the correctness tests that turn a suspect into a verdict stay on your side, and nothing here guarantees a performance, scalability, or reliability outcome.
That split is not a disclaimer bolted on the end — it is the method. A review that respects it produces ranked suspects and the checks that would convict them. A review that ignores it produces confident sentences about speed that nobody measured, which is the exact failure the whole exercise is meant to avoid.
Step 1: Frame the review before you read a line
A finding has no size without a workload, so the workload comes first. Name what you are reviewing and the path it sits on: is this the request every user hits on load, a background job that runs nightly, or a helper called once at startup? A quadratic loop on the startup path and the same loop on the hot path are not the same finding, and only the context tells them apart.
Then pin the numbers that turn "expensive" into something decidable: how often this runs, the typical and the worst-case input size, and the objective — a latency target, a throughput floor, a memory ceiling. You will not have all of them, and that is fine; the ones you do not have become the first things the review asks you to measure. What matters is that a complexity claim now has a size to be judged against, instead of floating free as "this could be slow."
This is also where you separate what you know from what you are guessing. "The endpoint feels slow for heavy users" is a report, not a measurement. Write it down as an assumption, because the review will treat it as a hypothesis to test rather than a fact to optimize around.
- State the path: hot request, background job, or one-time startup. It changes every priority downstream.
- Give the input a typical and a worst case. Complexity without a size is not a finding.
- Name the objective — latency, throughput, or memory. Without it, "slow enough to fix" has no meaning.
- Mark every number you do not actually have as a measurement to take, not a blank to fill with a guess.
Step 2: Separate what the code shows from what you are assuming
The single habit that keeps a performance review honest is labelling. Every line in the review is one of a few things, and they are not interchangeable: something observed directly in the code, something assumed about the workload, a hypothesis about cost, a measurement that is needed, a candidate change, its trade-off, and the validation that would confirm it. "This runs once per row" is observed. "There are usually thousands of rows" is assumed. "So this dominates the request" is a hypothesis — and it stays a hypothesis until a timer says so.
This matters because the failure mode of an AI review is fluent conflation: it will write "this allocation is killing your latency" in the same confident register as "this loop runs n times," and only one of those is visible in the code. The first is a guess wearing the clothes of an observation. Making the review tag each claim forces the guess back into the open, where you can decide whether it is worth the measurement to settle.
Ask the review, explicitly, to say what it cannot know from the code alone. A good performance review is comfortable writing "the real cost here depends on the row count, which is not in the code — measure it" instead of inventing a plausible answer. That sentence is worth more than a paragraph of confident diagnosis.
- Tag each claim: observed / assumed / hypothesis / measurement-needed / candidate / trade-off / validation.
- An allocation you can see is observed; the latency it costs is a hypothesis until timed.
- Force the review to name what the source cannot reveal — row counts, call rates, the real hotspot.
- A sentence that says "measure this, I cannot see it" is a feature of the review, not a gap in it.
Step 3: Walk the cost surfaces, not a quality checklist
This is where a performance review earns its name and stays clear of a general code review. You are not looking at naming, style, or whether the abstraction is clean — you are walking the places runtime cost hides. Work that grows with input: nested iteration, repeated sorting or filtering, parsing or serializing the same thing more than once, building large temporary collections, loading a whole set before filtering it down. Work that crosses a boundary: synchronous I/O on a hot path, a query inside a loop, the N+1 that fires one more round trip per item, repeated remote calls that could be batched, a result set pulled whole instead of paged or streamed.
Then the concurrency and contention surfaces, which cut both ways. Independent operations run serially when they could overlap — but the fix is rarely "make them parallel," because unbounded parallelism trades a latency problem for a resource-exhaustion one, saturating a connection pool or a thread pool under real load. Shared mutable state and lock contention do the opposite, quietly serializing work you thought was concurrent. And over-fetching — selecting columns nobody reads, returning fields the caller ignores — is cost you pay on every single call.
Caching belongs on this walk, but as a question, never as an answer. Every "just cache it" hides a design: what is the key, when does it invalidate, is stale data acceptable, what does a miss cost, how much memory does it hold, and does it need to stay consistent across instances? A cache that answers those is a real fix. A cache that skips them is a correctness bug you have not hit yet. The review flags the candidate; it does not get to wave the questions away.
- Hunt cost, not quality: growth with input, boundary crossings, contention, over-fetch — not naming or style.
- N+1 and queries-in-loops are the highest-yield reads, because they scale with the data, not the code.
- Serial-but-independent is a candidate to overlap — but bounded, or parallelism becomes pool exhaustion.
- Treat every cache suggestion as six questions (key, invalidation, staleness, miss, memory, consistency), not a fix.
Step 4: Rank by evidence, and refuse the invented percentage
A flat list of findings is barely more useful than no list, because it implies they all matter equally and they never do. Rank them — but rank them on things you can actually reason about: how much a finding could plausibly cost, how often the path runs, how fast the input grows, how much the supplied evidence backs the finding, how risky the change is, and how expensive the confirming measurement would be. A linear-scaling N+1 on the hot path outranks a quadratic sort over a set you know stays tiny, even though "quadratic" sounds scarier — because frequency and growth, not the exponent alone, decide what production feels.
What the ranking must never contain is a manufactured number. "This change will make it 40% faster" is not a finding; it is a fabrication with a decimal point, because the speedup depends on where the time actually goes, which nobody has measured yet. Rank by impact and confidence in words, name the measurement that would put a real number on it, and leave the number blank until the measurement fills it. A review that hands you percentages it could not have computed has stopped being evidence and started being decoration.
The output you want is a short ordered list where each item carries its own reasoning: the pattern, why it is ranked where it is, and the single measurement that would move it up or drop it off. That is a thing an engineer can act on. A ten-item pile of equal-weight smells is a thing an engineer ignores.
- Rank on impact × frequency × input growth × evidence × change risk × measurement cost — not on which word sounds worst.
- A frequent linear cost usually beats a rare quadratic one. The exponent is not the priority by itself.
- No invented gains. "40% faster" with nothing measured is a number the review had no way to know.
- Each ranked item names the one measurement that would promote or retire it.
Step 5: Protect behavior, then plan the proof
A performance change is only a win if the answer stays correct, and the fastest way to ruin a service is to speed up a function that now returns something subtly different. So the first thing a candidate change needs is not a benchmark but a guard: what must stay true regardless. Batching a per-row query must not drop the rows that had nothing to batch; parallelizing must not reorder a result the caller depends on; caching must not serve one user another user's data. Pin the current behavior with a characterization test before you touch anything, so a change that alters the output cannot pass quietly.
Only then does the benchmark plan matter, and it is an outline, not a scoreboard: same inputs on the same data, measure the baseline, apply one change, measure again, and attribute the difference to that change and not to the weather. Change one thing at a time or you will not know which one paid. And keep every candidate out of production until it is proven there, because a speedup that showed up on your laptop and vanished under real concurrency is common enough to expect.
This is also where the review hands off. The moment you have a candidate worth measuring and fixing, you are past reviewing and into diagnosing and optimizing — a different job with its own path. The review's deliverable is the ranked suspect and the check that would convict it; carrying that to a verdict is measurement, and measurement is the reader's to run.
- Pin behavior first: a characterization test that fails if the output changes at all.
- Benchmark as an outline — same data, one change, measure, attribute. Not a number you hoped for.
- Keep the change out of production until it is proven in production-like conditions.
- A faster-but-different result is a bug that benchmarks reward. The correctness guard is what catches it.
Common mistakes
The ways a performance review turns into noise, or worse:
- Taking the smell list as the review. Ten plausible findings with no ranking and no workload is a starting point someone still has to turn into a review.
- Optimizing a line nobody measured. The scariest-looking loop is often over a set that stays small; the real cost is usually somewhere less dramatic.
- Accepting a percentage the review could not have computed. A speedup number with no measurement behind it is invented, however precise it sounds.
- Treating "cache it" as a fix. Without a key, an invalidation rule, and a staleness decision, a cache is a correctness bug scheduled for later.
- Parallelizing independent work without a bound. Trading a slow path for an exhausted connection pool is not an optimization, it is a different outage.
- Assuming cleaner code is faster code. Readability and speed are both worth having and they are not the same axis — a refactor with no performance rationale is a refactor, not a fix.
- Shipping the speedup without pinning the behavior. A change that returns a different answer faster is the one benchmarks are happiest to pass.
A worked example: a reporting endpoint that scales with the customer
Here is a reporting endpoint that behaves fine in a demo and gets slower for exactly the customers you least want to annoy — the ones with the most projects. It is a good review target because almost everything interesting is decided before a single benchmark runs.
Read the shape rather than the prose: four candidate findings, ranked by likely cost and how they grow rather than by how alarming they sound; one line that looks like a problem and is deliberately parked; one finding ruled out on a second look; a correctness constraint the fix has to honor; and a measurement plan that can actually tell the findings apart. No number in it is invented — where a value would be measured, it says so.
One endpoint, four candidates ranked by likely cost and growth, one parked as low-priority and one ruled out on a second look — and every performance number left as a measurement to take, not a figure to inventTHE ENDPOINT (Python, /reports/projects, runs on every dashboard load):
projects = db.query(
"SELECT * FROM projects WHERE owner_id = ?", user_id) # 1 query, N rows
rows = []
for p in projects:
settings = db.query(
"SELECT * FROM project_settings WHERE project_id = ?", p.id) # 1 query PER project
defaults = json.parse(DEFAULT_PREFS_JSON) # the same global blob, re-parsed every pass
prefs = json.parse(p.preferences_blob) # each project's OWN blob
rows.append(summarize(p, settings, merge(defaults, prefs)))
rows.sort(key=lambda r: r.created_at) # sort the whole set in memory
return rows
THE FRAME (what turns a smell list into a review):
critical path : dashboard load -- every user hits it, every session
frequency : once per dashboard open (exact rate: TBD)
input size N : projects per user (distribution: TBD)
objective : dashboard should feel instant; call it a p95 target
the team has NOT set yet -> TBD, and it gates what
counts as "slow enough to fix"
FOUR CANDIDATES, EACH TAGGED BY WHAT IS KNOWN:
[MAJOR] N+1 queries observed from code
1 projects query + 1 settings query per project = 1 + N queries.
Grows one-for-one with N, and each one is a database round trip --
the costliest thing on this path per operation. Highest likely impact
AND highest growth, which is why it leads the list -- and it gets
worse for exactly the users who have the most projects.
candidate : one batched "WHERE project_id IN (...)" or a join.
measure : query count per request (query log / APM) x per-query
latency. Both TBD.
[MINOR] redundant parse observed from code
DEFAULT_PREFS_JSON never changes across the loop, yet it is parsed
on every pass -- N parses of one constant, plus the garbage each
parse makes.
candidate : parse it once, above the loop -- but only if merge()
does NOT mutate the defaults it is given. If merge writes
into defaults, one hoisted copy accumulates every
project's prefs and the output changes: hoist a fresh
copy per row, or a frozen constant, not a shared mutable.
why MINOR : free to remove and certainly redundant, so it is worth
doing -- but an in-process parse is almost certainly
dwarfed by a database round trip, and HOW MUCH it costs is
unmeasured. Cheap and certain is not the same as high
impact; a profiler decides its real weight, not this list.
[MINOR] over-fetch observed from code
SELECT * pulls columns summarize() never reads.
candidate : select the columns actually used.
caveat : low confidence until row width and volume are measured;
do not rank it above the N+1.
[NIT / deprioritized] in-memory sort observed from code, NOT a
priority without evidence.
O(M log M) LOOKS expensive, but M is bounded by N, and N is one
user's project count -- small. Only worth pushing into an indexed
ORDER BY if the size measurement says M is large. Parked, not fixed.
ONE FINDING RULED OUT, ON A SECOND LOOK:
json.parse(p.preferences_blob) reads like the same waste as the
redundant parse above -- but each project's blob is DIFFERENT, so
parsing each once is inherent work, not repetition. There is nothing
to hoist. Dropped from the list.
THE CORRECTNESS CONSTRAINT ON THE FIX:
batching the settings query must keep projects that have NO settings
row (a left join, not an inner one), map settings by project_id and
not by row order, and return rows in the order the client already
depends on. A faster endpoint that drops a project is a bug, not a win.
WHY CACHE IS NOT THE FIRST MOVE:
a cached report has to key on user + the set of projects + a settings
version; invalidate the moment any project or setting changes; decide
whether a stale report is acceptable (it shows wrong numbers); carry a
memory cost; define what a cache miss does; and stay consistent across
instances. Batching the query is cheaper and lower-risk, so it goes
first. Cache is a later question, not the opening answer.
THE RISKY VERSION OF THE FIX, NAMED SO IT IS NOT TAKEN:
firing the N settings queries concurrently instead of batching them.
Under a user with many projects and many such requests at once, that
is N x (concurrent requests) database connections -- a good way to
drain the pool and make everything slow. One batched round trip beats
N fast ones. Do not parallelize what you can combine.
THE MEASUREMENT THAT DECIDES (before any change ships):
- count queries per request -> isolates the N+1 from everything else
- time the DB calls, the parses, and the sort SEPARATELY -> tells you
which finding actually owns the wall clock
- capture the real spread of N -> tells you if the sort ever matters
every number here is TBD until measured. The review names the suspects;
the profiler says which one is guilty.
BEFORE / AFTER, AS AN OUTLINE (no invented numbers):
baseline : query count TBD, endpoint p95 TBD, parse time TBD
after batch : query count expected ~2 (confirm), p95 TBD
after hoist : parse time TBD
and: the response body must come back identical to the baseline on the
same data -- pin it with a characterization test first, then compare.
Where this fits in NewPrompt
The Code Review Prompt Generator is the entry point: choose the performance focus, set your language and the scope you are reviewing, paste the code or leave a placeholder, and it builds the review prompt — in your browser, running nothing. Its performance criteria are the cost surfaces from Step 3, allocations in hot paths through N+1 to resource lifecycle, and its output is a review that reports findings rather than rewriting your code. What comes out is prompt text; you run it in your own assistant, against your own code, with the workload facts from Step 1 stated in the objective.
The Performance Code Review Prompt is that same review already built for a worked case — the static version you can read to see the shape before you generate your own, with severity tags and the load stated up front. Its own notes draw two of this guide's boundaries in a line each: profiling is what convicts a suspect the review only names, and deep SQL query tuning is a different tool with the dialect and the plan in front of it. That second line is the fence this guide keeps: an N+1 you can read is a candidate; the index that fixes it is not something a review of the code alone can prescribe.
Two resources handle the edges the review hands off to. The Regression Test Prompt pins what the code does today, bugs and all, so a performance change cannot alter the answer without a test noticing — the guard Step 5 asks for. And the Debug Performance Problems Prompt is the other side of the boundary: once you have a live, measured slowness rather than a code-read suspicion, that is where the work goes, and its own description sends the no-measurement case back to the review. Reviewing looks at code before there is a number; debugging starts once there is one.
When a candidate is worth taking all the way, the AI Performance Optimization Workflow is the longer path — find the real bottleneck, understand why it is slow, change the hot path, and prove both that the speed moved and that the behavior did not. The review is the front of that: it produces the ranked suspect and the measurement that would confirm it, and the workflow is where measuring, fixing, and verifying actually happen. What the review never does is tell you the answer. It tells you where to point the profiler, and that is the honest half of the job that reading can do.