teachyou.ai academy
← All posts
Claude Code

Claude Code for Performance Optimization: Finding Bottlenecks

Ira Menon · Jun 21, 2026 · 15 min read

Why "it feels slow" is the worst starting point

Every performance investigation starts the same way: someone says the dashboard feels sluggish, or the checkout API takes "forever," or the CI suite has crept from four minutes to fourteen. None of that is a bug report you can act on. Performance work only becomes tractable once you have numbers — a flame graph, a trace, a query plan, a load test report. The moment you have that artifact, you have something Claude Code can actually reason about.

This is the core idea behind using Claude Code for performance optimization: it is not a magic "make it faster" button, and it should never be pointed at a codebase with an instruction like "optimize this." It is a very fast, very literate pair for reading dense profiler output, connecting that output to specific lines of source code, proposing a targeted fix, and then verifying the fix actually moved the number. The workflow is profile, diagnose, fix, re-profile — Claude Code accelerates every step except the part where you actually run the profiler, and it can even help you set that up too.

This article walks through that workflow end to end: instrumenting code to produce real data, feeding that data to Claude Code inside your terminal session, interpreting what comes back, and building a re-profile loop so you know a "fix" isn't just a guess that happened to compile.

Step 1: Get a profiler in the loop before you touch any code

The single biggest mistake in performance work is jumping to source code first. Reading code and guessing where the slowness lives is how you end up optimizing a function that accounts for 2% of runtime while the actual 60% sits somewhere you never looked. Claude Code is genuinely good at reading code, but it cannot see your production traffic patterns, your data distribution, or your actual call frequency — so the first step has nothing to do with Claude Code at all. It's picking the right profiler for the layer you suspect is slow.

For a Python backend, cProfile combined with snakeviz or py-spy for live sampling is usually the fastest path to a real signal:

python -m cProfile -o profile.stats manage.py runserver
# or, for a running process without restarting it
py-spy record -o profile.svg --pid 48213

For Node.js services, the built-in inspector plus 0x or Chrome DevTools' CPU profiler gives you a flame graph without adding dependencies:

node --prof server.js
node --prof-process isolate-0x*.log > processed.txt

For database-bound slowness, skip the application profiler entirely and go straight to the query planner:

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders
WHERE customer_id = 4821 AND status = 'pending';

Whatever you use, the goal is the same: produce a text or JSON artifact that names functions, line numbers, and time spent. That artifact is what you hand to Claude Code. If you're not sure which profiler fits your stack, that's a completely reasonable first question to ask Claude Code directly in your terminal — describe the symptom ("API endpoint takes 800ms, mostly CPU not I/O, it's a Django app") and let it recommend the tool and the exact invocation for your environment before you generate any data.

Step 2: Feed Claude Code the real artifact, not a description

Once you have profiler output, resist the urge to summarize it in your own words when you ask for help. Paste the actual output, or better, point Claude Code at the file directly so it can read the whole thing rather than the ten lines you thought were relevant.

A concrete example: say cProfile output showed this near the top, sorted by cumulative time:

ncalls  tottime  percall  cumtime  percall filename:lineno(function)
  1      0.002    0.002   0.812    0.812  views.py:44(get_dashboard)
  1200   0.041    0.000   0.734    0.001  models.py:118(get_recent_orders)
  1200   0.612    0.001   0.612    0.001  models.py:130(_serialize_order)
  1      0.003    0.003   0.041    0.041  views.py:52(get_user_prefs)

The pattern here is visible immediately, even before Claude Code gets involved: _serialize_order is called 1,200 times and eats 0.612 seconds of total time — the overwhelming majority of the endpoint's 0.812-second cumulative cost. That's the N+1-shaped smell: something being called once per row instead of once per batch.

Handing this to Claude Code with a prompt like "here's cProfile output for our /dashboard endpoint, models.py:130 is the hot path, read that function and the caller and tell me what's expensive" gets you a much sharper diagnosis than "why is my dashboard slow." Claude Code can open models.py, read _serialize_order and get_recent_orders together, and check whether the serializer is issuing a fresh query per row — which is exactly the kind of cross-file reasoning that's tedious to do by hand but fast for an agent that can grep the whole repo in seconds.

Step 3: Let Claude Code trace the call graph, not just the hot line

Profiler output tells you *where* time goes, not *why*. The "why" almost always requires reading a few layers of the call stack, and this is where Claude Code earns its keep versus manually cross-referencing files.

A useful pattern is to ask explicitly for a trace: "Show me every place _serialize_order is called, and for each caller, tell me if it's inside a loop." Claude Code will grep the codebase, find the call sites, and — critically — read enough surrounding code to answer the loop question correctly rather than just pattern-matching on the function name.

In the dashboard example, following that thread typically surfaces something like this in models.py:

def get_recent_orders(customer_id):
    orders = Order.objects.filter(customer_id=customer_id)[:1200]
    return [_serialize_order(o) for o in orders]

def _serialize_order(order):
    return {
        "id": order.id,
        "total": order.total,
        "customer_name": order.customer.name,   # triggers a query per order
        "items": [i.sku for i in order.items.all()],  # triggers another query per order
    }

Now the profile output makes complete sense: order.customer.name and order.items.all() are each lazy-loaded relations, so every single call to _serialize_order fires two additional database round trips that don't even show up as separate line items in the Python profiler because they're buried inside Django's ORM internals. This is the moment profiling and code reading combine into an actual diagnosis: 1,200 orders times two lazy loads is 2,400 extra queries hiding behind one cumulative time number.

This is worth calling out because it's a common trap: a CPU profiler will show you time spent in _serialize_order, but the real cost is I/O latency incurred *by* that function, not CPU work happening *in* it. Claude Code reading the actual model definitions is what catches this — a flame graph alone would have you staring at a function that "looks" expensive without ever seeing the ORM relationship that's the actual root cause.

Step 4: Ask for the smallest fix that addresses the measured cause

This is the step where scope discipline matters most. Once a diagnosis exists, it's tempting to ask for a broad rewrite — "refactor this whole module to be efficient." Don't. The fix should be scoped exactly to what the data showed, because every line you change outside that scope is a line you now have to verify didn't introduce a regression, and it dilutes the before/after comparison you're about to run.

For the N+1 case above, the fix is a single-line change to the query, using select_related and prefetch_related to eager-load the two relations that were causing per-row queries:

def get_recent_orders(customer_id):
    orders = (
        Order.objects
        .filter(customer_id=customer_id)
        .select_related("customer")
        .prefetch_related("items")
        [:1200]
    )
    return [_serialize_order(o) for o in orders]

select_related folds the customer join into the original query via a SQL JOIN. prefetch_related issues one extra query for all items across all 1,200 orders, batched, instead of 1,200 separate queries. The _serialize_order function itself doesn't need to change at all — it's still doing the same dict construction, just against data that's already in memory.

When you ask Claude Code to apply a fix like this, be explicit about the constraint: "change only the query in get_recent_orders, don't touch _serialize_order, don't change the response shape." Constraining the diff this way makes the fix easy to review in a few seconds and keeps your re-profile comparison clean — you know exactly one thing changed, so any change in the numbers is attributable to it.

Step 5: Re-profile with the same tool and same conditions

A fix without a re-measurement is a hypothesis, not a result. This step gets skipped more often than it should, usually because the first profiler run already felt like enough work. But the entire value of this workflow collapses if you don't close the loop — you need the same profiler, ideally the same input data or a fixed seed, run the same way, to get a comparable number.

python -m cProfile -o profile_after.stats manage.py runserver

Then ask Claude Code to diff the two profiler outputs directly rather than eyeballing them — it's fast at parsing two .stats dumps or two flame graph JSON exports and pulling out the specific functions that moved. In the dashboard example, a realistic before/after comparison looks like this:

Before: get_dashboard cumtime = 0.812s, _serialize_order = 0.612s (1200 calls)
After:  get_dashboard cumtime = 0.091s, _serialize_order = 0.038s (1200 calls)

That's the artifact that goes in the PR description — not "should be faster now," but the actual before and after profiler numbers, tied to the actual endpoint, produced by the actual tool. If the fix doesn't move the number the way the diagnosis predicted, that's valuable information too: it means the diagnosis was wrong or incomplete, and you're back to Step 3 with a narrower search space, not starting over.

Common bottleneck patterns Claude Code is good at spotting

A few shapes come up often enough across backend, frontend, and data-pipeline code that it's worth training your eye — and your prompts — to look for them specifically, since naming the pattern you suspect gets you a sharper answer than a vague "look for slow stuff."

  1. N+1 queries — a loop that issues one database call per iteration instead of one batched call. The tell in profiler output is a function with a high ncalls count and moderate percall time that, multiplied out, dominates cumulative time.
  2. Unbounded result sets — pagination or LIMIT clauses missing on queries that used to return ten rows in dev and now return two hundred thousand in production. Ask Claude Code to grep for query calls without a limit/LIMIT/[:n] nearby.
  3. Synchronous I/O inside a hot loop — an HTTP call, file read, or subprocess spawn sitting inside code that runs per-item instead of once, batched, or in parallel.
  4. Redundant recomputation — the same expensive pure function called repeatedly with identical inputs inside a request lifecycle, a candidate for memoization once you've confirmed via the profiler that it's actually hot and not just frequently called.
  5. Serialization overhead — large objects being fully serialized (to JSON, to a DTO, to a template context) when only a handful of fields are actually used downstream.
  6. Missing or unused indexes — visible in EXPLAIN ANALYZE output as a sequential scan on a large table where a filter predicate should be hitting an index; Claude Code can read your migration files to check whether an index exists and was simply never applied.

For each of these, the workflow is identical: get the profiler artifact that confirms the pattern is actually costing real time in your specific case, hand that artifact to Claude Code with the suspected pattern named explicitly, get a scoped fix, re-profile.

It also helps to ask Claude Code to rank suspected bottlenecks by expected impact before you start fixing anything, especially when a single profiler run surfaces three or four plausible culprits at once. A prompt like "given this cProfile output, rank the top five functions by cumtime and tell me which ones are called once per request versus once per row" turns a wall of numbers into a prioritized list, so you fix the thing that's actually worth 400ms before spending an afternoon shaving 8ms off something else. This matters more than it sounds — it's common to find a genuinely inefficient function that nonetheless accounts for a tiny fraction of total request time, and chasing it feels productive without moving any real metric.

Frontend and build-time bottlenecks follow the same loop

Everything above centers on backend and database code because that's where profiler output is most unambiguous, but the identical profile-diagnose-fix-reprofile loop applies to frontend rendering and build tooling too. A React app with a janky scroll gets a Chrome DevTools Performance recording exported as a .json trace; a slow Vite or Webpack build gets --profile flags that emit a stats file.

# Vite build profiling
vite build --profile
# Webpack bundle analysis
webpack --profile --json > stats.json

The pattern that shows up most often on the frontend side is components re-rendering far more than the data actually changed — visible in a React DevTools Profiler flame graph as the same component name appearing dozens of times per interaction. Handing that flame graph export to Claude Code alongside the component file usually surfaces a missing useMemo, a new object or array literal being passed as a prop on every render (defeating React.memo), or a context provider re-rendering its entire subtree when only one consumer actually needed the update. The fix is again narrow and specific — wrap one computation, stabilize one prop reference — and the re-profile step is the same DevTools recording taken again under the same interaction to confirm render count actually dropped.

Build-time slowness follows the same shape: a bundle stats JSON will show which modules are pulled in unnecessarily (a whole date library imported for one formatting function, a UI kit imported in full instead of per-component), and Claude Code reading the actual import statements can tell you exactly which import to narrow, rather than guessing from the bundle size alone.

Where this workflow breaks down

It's worth being honest about the limits here, because performance work is one of the easier places to fool yourself. Claude Code cannot observe your production traffic shape, so a fix validated against a local profiler run with synthetic data can behave differently under real concurrency, real cache hit rates, or real data skew — always validate meaningful fixes against a staging environment or a load test that approximates production before calling the work done. It also can't tell you whether a "fix" traded one bottleneck for another lower down the stack; that's exactly what the re-profile step exists to catch, which is why skipping it is the most common way this workflow produces false confidence. And for genuinely novel algorithmic problems — a fix that requires restructuring a data model or changing an API contract — treat Claude Code's suggestion as a strong first draft for a design discussion, not a change to merge straight from the diagnosis.

The other failure mode worth naming: prompting with "make this fast" instead of a measured artifact. Without a profiler output in front of it, Claude Code will still read the code and make reasonable-sounding suggestions, but reasonable-sounding is not the same as measured-correct. The entire value of this article's workflow comes from putting real numbers in front of the model at every step — before the fix and after it — rather than trusting intuition about what "looks slow."

A related trap is accepting a fix that improves the profiler number but breaks correctness — a batched query that silently drops rows when an ID list happens to be empty, or a memoized function that ignores one of its actual inputs and returns stale results under specific conditions. Speed and correctness are not the same axis, and a re-profile run only tells you about one of them. Always run the existing test suite alongside the re-profile step, not instead of it, and if the code path didn't have adequate test coverage before you started, treat writing that coverage as part of the same unit of work rather than a follow-up task that quietly never happens. A performance fix that regresses correctness is strictly worse than the slow version, because the slow version was at least honest about what it was doing.

Benchmark noise is worth guarding against too. A single load-test run or a single profiler pass can be thrown off by a background process, a cold cache, or JIT warm-up effects, especially on a shared machine or a laptop running other tools. Ask for at least three runs and look at the median rather than trusting one number that happens to look good. If Claude Code reports a "2x improvement" based on a single before/after pair, it's reasonable to push back and ask for repeated runs before treating that number as real.

Building the habit into your normal workflow

None of this requires new tooling beyond what you likely already have installed — cProfile, py-spy, Chrome DevTools, EXPLAIN ANALYZE, and Webpack/Vite's built-in profiling flags cover the overwhelming majority of real-world bottlenecks across backend, frontend, and database layers. What changes is the discipline: generate the artifact first, hand Claude Code the artifact and not a description, ask it to trace the call graph rather than guess from the hot line alone, constrain the fix to exactly what the data showed, and always close the loop with a second profiler run using the same method as the first.

Treat every performance PR as a before/after pair of numbers, not a claim. That habit alone — refusing to say "should be faster" without a profiler run to back it up — will save you from an enormous amount of wasted optimization effort chasing bottlenecks that were never real in the first place.

If you want to build this kind of workflow discipline from the ground up — reading profiler output, scoping prompts correctly, and using Claude Code across a real debugging and optimization session rather than toy examples — that's exactly what we cover hands-on in the Claude Code Tutorial for Beginners course on teachyou.ai.