# Application performance optimization with Cursor

URL: https://developertoolkit.ai/en/cursor-ide/operate/performance/
Description: Measure first, let Cursor's agent fix N+1 queries, bundle weight and re-renders, then prove each win with a rerun baseline, unchanged tests and a CI budget.

Application performance optimization with Cursor is a measure-first loop: record a baseline with real tools, have the agent rank bottlenecks from that data, fix one bottleneck per change (N+1 queries, bundle weight or re-renders), and accept the change only when a rerun of the same measurement, an unchanged test suite and a CI budget all confirm the gain.

This page is for the developer who owns a slow feature and the tech lead who has to approve the fix. Your SaaS billing dashboard takes 6 seconds to load, its usage endpoint has a p95 of 2.4 seconds, and database CPU jumps every time someone opens the **Usage analytics** tab. The agent will happily "optimize" anything you point it at, so the risk is not a lack of changes. The risk is a plausible diff that makes nothing faster, or makes it faster and wrong.

Scope: this page covers your application's code. If Cursor itself is slow (indexing, extensions, memory), see [tuning Cursor for large projects](/en/cursor-ide/operate/performance-optimization/). For production infrastructure problems such as pool exhaustion or autoscaler flapping, see [production performance optimization](/en/workflows/operate/performance-tuning/).

## What you get from a measure-first performance loop

- A baseline folder (`perf/baseline/`) produced by commands you run, so no number in the PR comes from the model's imagination
- Two read-only prompts that turn raw timings, query logs and traces into a ranked list of bottlenecks
- Fix prompts for N+1 queries, bundle weight and React re-renders that each carry their own guard test
- A CI bundle budget and a query-count test that keep the win from quietly regressing
- A before/after table a reviewer can approve without reading every changed line

## Which tool measures which symptom?

Pick the measurement by symptom before you write a prompt. The agent is good at reading large outputs; it is not a profiler, and a number it "reports" without a file behind it is a guess.

| Symptom | Measure with | What the agent reads |
| --- | --- | --- |
| Slow API endpoint, high p95 | [`autocannon`](https://github.com/mcollina/autocannon) 8.0.0 load run with `-j` JSON output | `perf/baseline/*-load.json` |
| Database CPU spikes, many queries per request | The ORM's query logger (for Drizzle, `logger: true`) plus a per-request query counter | Query log grouped by request ID |
| Slow first load, large JavaScript | `npx next experimental-analyze` (Next.js 16.1+, Turbopack only) or `@next/bundle-analyzer` 16.3.6 (webpack builds) | Analyzer output and chunk sizes |
| Poor LCP, INP or CLS in the browser | Lighthouse 13.5.0 CLI, or a trace through the [Chrome DevTools MCP server](/en/ecosystem/mcp/browser-automation/) | Lighthouse JSON, trace insights |
| Janky interaction after load | React DevTools Profiler, exported with **Save profile** | The exported profile JSON |

Versions were checked on npm on 2026-09-26. The `web-vitals` package (6.2.2) exports `onCLS`, `onFCP`, `onINP`, `onLCP` and `onTTFB` and no `onFID`, so any generated code that calls `onFID` is out of date.

## How to capture a baseline the agent cannot invent

Run the measurements yourself (or let the agent run them in the terminal) and save the raw output to files. Every later comparison reads those files, never the chat history. Measure a production build: a dev server's timings are dominated by on-demand compilation.

```bash
# Terminal, against a production build (npm run build && npm run start)
mkdir -p perf/baseline
npx autocannon -c 20 -d 30 -j "http://localhost:3000/api/billing/usage?month=2026-09" \
  > perf/baseline/usage-load.json
npx lighthouse http://localhost:3000/billing --only-categories=performance \
  --preset=desktop --output=json --output-path=perf/baseline/billing-lighthouse.json --quiet
```

The load run needs a request-level view too: how many queries each request runs and how long they take. That instrumentation is the first thing to ask the agent for.

**Copy-paste prompt: add measurement without optimizing**

```text
@src/app/api @src/db @package.json

Add performance measurement to this app. Do not optimize anything yet.

1. Request timing: add middleware that logs one JSON line per API request to stdout with
   method, path, status, duration_ms, db_query_count, db_time_ms and a request_id.
2. Database: enable the Drizzle query logger and route each query (sql, duration_ms) to the
   current request_id using AsyncLocalStorage, so db_query_count and db_time_ms are real.
3. Browser: report LCP, INP, CLS, FCP and TTFB with the web-vitals package (onLCP, onINP,
   onCLS, onFCP, onTTFB; there is no onFID). console.log in development, POST to
   /api/metrics in production.
4. Keep all of this behind PERF_LOGGING=1 so production traffic is unaffected by default.

Then show me the exact commands to reproduce the slow billing dashboard load and where
each log line ends up. Do not change any business logic.
```

Once the instrumentation is in, capture the request log. The baseline block above ran before it existed, so rerun the load test with logging on:

```bash
# Terminal 1: production server with request logging; keep only the JSON lines
PERF_LOGGING=1 npm run start --silent | grep --line-buffered '^{' > perf/baseline/requests.jsonl

# Terminal 2: rerun the baseline load test against it
npx autocannon -c 20 -d 30 -j "http://localhost:3000/api/billing/usage?month=2026-09" \
  > perf/baseline/usage-load.json
```

`--silent` drops npm's script banner, and the `grep` drops the startup lines `next start` prints, so `jq` later reads pure JSON Lines instead of failing on the first non-JSON line. Collect at least 100 requests to the slow endpoints in `perf/baseline/requests.jsonl`. autocannon reports p97.5 and p99, not p95; p95 comes from `requests.jsonl`. Commit the `perf/baseline/` folder on the branch; it is the evidence the reviewer compares against.

For browser work, the [Chrome DevTools MCP server](https://github.com/ChromeDevTools/chrome-devtools-mcp) (npm `chrome-devtools-mcp` 1.10.1, maintained by the Chrome DevTools team) lets the agent record a performance trace (`performance_start_trace` / `performance_stop_trace`) and read its insights (`performance_analyze_insight`). Its `lighthouse_audit` tool excludes the performance category (checked in 1.10.1), so keep the Lighthouse CLI run for LCP and TBT scores. Add it to `.cursor/mcp.json` (the shape comes from the vendor README; see [MCP setup in Cursor](/en/cursor-ide/quick-start/mcp-setup/)):

```json
{
  "mcpServers": {
    "chrome-devtools": {
      "command": "npx",
      "args": ["-y", "chrome-devtools-mcp@latest", "--isolated", "--no-usage-statistics", "--no-performance-crux"]
    }
  }
}
```

The server sends usage statistics to Google unless you pass `--no-usage-statistics`, and its performance tools can send trace URLs to the CrUX API unless you add `--no-performance-crux`. Add both flags for internal URLs. Each connected server also adds its tool definitions to the agent's context; see [managing tokens in Cursor](/en/cursor-ide/operate/token-management/).

## How do you find the real bottleneck from profiling data?

Ask for analysis in **Plan Mode**, which produces a plan before any code is written. The goal of this step is a ranked list with a confirmation test for each item, not a fix.

**Copy-paste prompt: rank the bottlenecks**

```text
@perf/baseline

Read requests.jsonl, usage-load.json and billing-lighthouse.json. Do not edit any file.

1. Per endpoint: request count, median, p95 and p99 duration_ms, median db_query_count,
   and the share of duration spent in db_time_ms.
2. Flag N+1 patterns: the same SQL shape repeated within one request_id with different
   parameters. Quote one example request_id and its repeated statement.
3. From Lighthouse: LCP element, total blocking time, and the three largest scripts.
4. List outlier requests (over 3x the endpoint median) and what they have in common.

Rank the top three opportunities by expected impact on p95 or LCP. For each, state the
hypothesis, the single measurement that would confirm or refute it, and the file you
suspect. If the data cannot distinguish two causes, say so instead of guessing.
```

Check two numbers from the agent's answer against the raw file yourself, for example the p95 of one endpoint:

```bash
# Terminal: nearest-rank p95 of duration_ms for one endpoint
jq -s '[.[] | select(.path=="/api/billing/usage").duration_ms] | sort | .[((length*0.95)|ceil) - 1]' perf/baseline/requests.jsonl
```

If they disagree, the agent sampled or misread the log, and the ranking is not trustworthy until it recomputes. When a ranked item names a specific cause, confirm it before the fix: an N+1 claim is confirmed by one request's log lines, a bundle claim by the analyzer output.

## How to fix N+1 queries without changing the response

An N+1 query loads parent rows and then runs one query per row. In the billing scenario the usage endpoint runs one query for enterprise organizations and then one `SUM(amount)` per organization: 341 queries for 340 organizations.

Before the fix, freeze the behaviour. A snapshot of the current JSON response and a test that counts queries per request turn "the diff looks right" into two assertions that fail if it is not.

**Copy-paste prompt: replace an N+1 with one grouped query**

```text
@src/app/api/billing/usage/route.ts @src/db/schema.ts @perf/baseline/requests.jsonl

The usage endpoint has an N+1: one SELECT for organizations WHERE plan = 'enterprise',
then one SELECT SUM(amount) FROM usage WHERE org_id = ? AND month = ? per organization.

Work in this order and stop after each step so I can run the tests:
1. Write a test that seeds 3 enterprise orgs (one with no usage rows for the month), calls
   the endpoint, and snapshots the JSON response exactly as it is today.
2. Write a test that asserts the endpoint runs at most 2 queries per request, using the
   db_query_count from our request logging. It must fail on the current code.
3. Rewrite the handler as a single Drizzle query with a LEFT JOIN on usage and GROUP BY
   org id, so orgs with zero usage still appear with 0. Keep the response shape identical.
4. If no index covers usage(org_id, month), add a migration for it and tell me how many
   writes per second the usage table receives today before I apply it.

Both tests must pass and the snapshot must be unchanged. Do not update the snapshot.
```

The `LEFT JOIN` line is not decoration. The most common wrong rewrite of this pattern is an inner join, which drops every organization with no usage rows for the month. The response looks right in a demo and silently loses rows in production. The snapshot with a zero-usage organization is what catches it.

## How to cut bundle weight and keep it cut

Start from the analyzer, not from a list of libraries you suspect.

**Next.js 16.1+ (Turbopack)**

```bash
# Terminal: interactive Turbopack bundle analyzer; does not produce an app build (Next.js 16.1+, Turbopack only)
npx next experimental-analyze
```

**webpack builds**

```bash
# Terminal: requires @next/bundle-analyzer wired into next.config.js
# Next.js 16 builds with Turbopack by default; this analyzer only runs with --webpack
ANALYZE=true npx next build --webpack   # writes client.html, edge.html and nodejs.html to .next/analyze/
```

If you want the analysis from a real production build instead, `next build --experimental-analyze` runs the same analyzer as part of the build (Turbopack only, checked in next 16.3.6).

Paste the largest modules from the report into the prompt. The numbers below are the billing dashboard's; replace them with yours.

**Copy-paste prompt: shrink the client bundle and add a budget**

```text
@package.json @next.config.js @src/app/(dashboard)

The bundle analyzer shows, in client JavaScript:
- lodash (full build) pulled in for 3 functions: debounce, groupBy, sortBy
- moment used in one date formatting helper
- chart.js imported by the layout but rendered only on /billing/analytics
- three date picker libraries; only react-day-picker is used in the current UI

Make these changes as separate commits, one per item:
1. Replace lodash imports with per-function imports from lodash-es.
2. Replace moment with date-fns in the helper; keep the exact output format and add a
   unit test that pins it for 3 dates, including one across a DST change.
3. Load the chart component with next/dynamic only on /billing/analytics.
4. Remove the two unused date picker packages after proving with a search that nothing
   imports them.
5. Add size-limit with @size-limit/file, a "size-limit" entry in package.json for the
   client chunks, and a "size" script for CI. Set the limit only after items 1-4 land:
   run size-limit, take the measured total and set the limit about 5% above it, then
   run npm run size and show me that it passes.

After each commit, rerun the analyzer and tell me the size change of that item only.
```

The [size-limit](https://github.com/ai/size-limit) budget (14.1.0, brotli-compressed sizes by default) is what keeps the gain. The ceiling sits just above the size you reached, not below it: a limit under the current total fails CI on the very commit that adds it. Without it, the next feature branch brings back a heavy import and nobody notices until Lighthouse scores drift.

## How to stop unnecessary React re-renders

When the bundle is reasonable and interactions still stutter, record the interaction in the React DevTools Profiler, save the profile, and give the agent the file and the components involved.

**Copy-paste prompt: remove re-renders the profile proves are wasted**

```text
@src/components/billing-dashboard.tsx @src/components/usage-chart.tsx @src/hooks/use-billing-data.ts @perf/baseline/hover-profile.json

Hovering the usage chart re-renders the whole dashboard. The profile shows BillingDashboard
(40 ms), UsageChart (25 ms), InvoiceList (15 ms) and UsageSummary (10 ms) on every tooltip
move. Only UsageChart should re-render.

1. Name the state change that triggers the parent render, citing the profile.
2. Move tooltip state into UsageChart so it no longer lives in the parent.
3. If useBillingData returns a new object each render, stabilize it with useMemo.
4. Add React.memo only to components the profile shows as expensive AND re-rendering
   without prop changes. For each memo you add, name the profile entry that justifies it.
```

Then record the same interaction again and compare the two profiles. A fix that adds `React.memo` to components that were cheap to begin with is overhead, not optimization.

## How to prove the win without reading every line

Rerun exactly the baseline commands into `perf/after/`, collect `perf/after/requests.jsonl` the same way as the baseline (at least 100 requests to the same endpoints), run the full test suite, and have the agent compare files, not memories.

**Copy-paste prompt: build the before/after evidence table**

```text
@perf/baseline @perf/after

Compare the baseline and after files. Do not rerun anything and do not estimate.
Produce one Markdown table with: endpoint p50, p95 and p99; median queries per request;
LCP, total blocking time and CLS; client JavaScript size. Columns: before, after, change %.

Mark any metric that got worse by more than 5% as a regression and list it first.
If a value is missing from either file, write "not measured" rather than filling it in.
```

Paste that table into the PR description. The reviewer then checks evidence instead of reading the diff line by line:

| Evidence | Proves | Who checks it |
| --- | --- | --- |
| `perf/baseline/` and `perf/after/` from the same commands on a production build | The gain is real and measured the same way twice | Tech lead, in the PR |
| Response snapshot test unchanged | The faster code returns the same data | CI |
| Query-count test (at most 2 queries per request) | The N+1 cannot return unnoticed | CI |
| `size-limit` budget | The bundle stays under the new ceiling | CI |
| Regression rows in the table | No other metric paid for this one | Tech lead |
| Bugbot review of the PR | A second reviewer scans for bugs and security issues in the change | Cursor's reviewer, then the author |

The author owns the numbers; the tech lead signs off on the table and the green gates and reads code only where the table shows a regression or a test was changed. This is the [evidence-over-diffs](/en/ladder/evidence-not-diffs/) way of reviewing, applied to performance. For recurring checks, [Cursor Automations](/en/cursor-ide/automate/cloud-agents-automations/) can start a Cloud Agent from a Sentry or PagerDuty event, so a latency alert arrives with the same baseline-and-rank analysis already attached.

## When agent-driven performance work goes wrong

**The agent optimizes a non-bottleneck.** You asked for a faster endpoint and got a 2 ms saving in a function inside a 2,000 ms request. Recovery: return to the ranking prompt and require that every fix names the baseline row it targets. A fix without a row does not get merged.

**The numbers in the PR came from the chat, not from a file.** The agent "ran a benchmark" in a dev server, or summarized a run that timed out. Recovery: accept only numbers present in `perf/after/`, produced by the same commands as the baseline, on a production build. The comparison prompt refuses to fill in missing values for this reason.

**The faster query returns different data.** An inner join drops empty groups, a new cache serves another tenant's totals, or a rounding change moves cents. Recovery: the response snapshot test fails; revert the handler and ask for the rewrite again with the failing case quoted. Never let the agent update a snapshot in the same change that makes the code faster.

**Memoization everywhere.** `React.memo`, `useMemo` and `useCallback` cost memory and comparison time. Recovery: remove every memo the profile does not justify, then re-profile; if the interaction time is unchanged, the memos were noise.

**A new index slows the write path.** Each index adds work to every insert on a high-write table such as usage events. Recovery: before merging, compare insert latency on a write-heavy load run; if it regresses, try a narrower composite index or a pre-aggregated table instead.

**Code splitting makes the page slower.** A lazily loaded chunk that is small and needed on most pages adds a request for little saving. Recovery: split only chunks that are both large and used on few routes, and check LCP in `perf/after/` rather than bundle size alone.

**The fast version is unmaintainable.** A hand-written query with nested CTEs beats the ORM by 30 ms and nobody can change it. Recovery: keep ORM code by default, drop to raw SQL only for a hot path with a measured requirement, and require a comment that links to the benchmark that justified it.

## Where to go next with performance work

The same measure-first loop, written for the other agents: [application performance analysis with Claude Code](/en/claude-code/operate/performance/) and [performance optimization with Codex](/en/codex/operate/performance/).

- [Monitoring and observability with Cursor](/en/cursor-ide/operate/monitoring/) — Turn the request timing and query logs from this page into metrics, traces and alerts.
- [Tuning Cursor for large projects](/en/cursor-ide/operate/performance-optimization/) — When the editor, not the app, is slow: indexing, extensions and memory.
- [Browser MCP servers](/en/ecosystem/mcp/browser-automation/) — Playwright and Chrome DevTools MCP compared, including performance traces.
- [Production performance optimization](/en/workflows/operate/performance-tuning/) — Cross-tool workflow for p99 regressions, pool exhaustion and autoscaling.
- [Database design with Cursor](/en/cursor-ide/plan/database-design/) — Use the query data from this page to inform indexes and denormalization.
- [Reading evidence instead of code](/en/ladder/evidence-not-diffs/) — Why a before/after table and green gates beat a line-by-line diff review.
