SuperAPI AI Directory

performance-optimization

Coding & development

View repository

Updated 10-16 08:44

About this skill

Performance Optimization

Overview

Measure first, then optimize. Performance work without measurement is guessing — and guessing leads to premature optimization, adding complexity without improving what actually matters. Profile first, find the real bottleneck, fix it, then measure again. Optimize only the parts that measurement proves matter.

When to Use

  • The spec has performance requirements (load time budgets, response time SLAs)
  • Users or monitoring report lag
  • Core Web Vitals scores are below the threshold
  • You suspect a change introduced a regression
  • Building features that handle large datasets or high traffic

When not to use: Do not optimize before there is evidence of a problem. The complexity introduced by premature optimization costs more than the performance it buys.

Core Web Vitals Targets

Metric Good Needs Improvement Poor
LCP(Largest Contentful Paint) ≤ 2.5s ≤ 4.0s > 4.0s
INP(Interaction to Next Paint) ≤ 200ms ≤ 500ms > 500ms
CLS(Cumulative Layout Shift) ≤ 0.1 ≤ 0.25 > 0.25

Optimization Workflow

1. MEASURE  → Establish baseline with real data
2. IDENTIFY → Find the actual bottleneck (not assumed)
3. FIX      → Address the specific bottleneck
4. VERIFY   → Measure again; keep or revert
5. GUARD    → Add monitoring or tests to prevent regression

Step 1: Measure

Two complementary approaches — use both:

  • Synthetic (Lighthouse, DevTools Performance tab): Controlled conditions, reproducible. Best for CI regression detection and isolating specific issues.
  • RUM(web-vitals library、CrUX): Real user data under real conditions. Required to verify that a fix actually improves the user experience.

Frontend:

# Synthetic: Lighthouse in Chrome DevTools (or CI)
# Chrome DevTools → Performance tab → Record
# Chrome DevTools MCP → Performance trace

# RUM: Web Vitals library in code
import { onLCP, onINP, onCLS } from 'web-vitals';

onLCP(console.log);
onINP(console.log);
onCLS(console.log);

Backend:

# Response time logging
# Application Performance Monitoring (APM)
# Database query logging with timing

# Simple timing
console.time('db-query');
const result = await db.query(...);
console.timeEnd('db-query');

Where to Start Measuring

Use symptoms to decide what to measure first:

What is slow?
├── First page load
│   ├── Large bundle? --> Measure bundle size, check code splitting
│   ├── Slow server response? --> Measure TTFB in DevTools Network waterfall
│   │   ├── DNS long? --> Add dns-prefetch / preconnect for known origins
│   │   ├── TCP/TLS long? --> Enable HTTP/2, check edge deployment, keep-alive
│   │   └── Waiting (server) long? --> Profile backend, check queries and caching
│   └── Render-blocking resources? --> Check network waterfall for CSS/JS blocking
├── Interaction feels sluggish
│   ├── UI freezes on click? --> Profile main thread, look for long tasks (>50ms)
│   ├── Form input lag? --> Check re-renders, controlled component overhead
│   └── Animation jank? --> Check layout thrashing, forced reflows
├── Page after navigation
│   ├── Data loading? --> Measure API response times, check for waterfalls
│   └── Client rendering? --> Profile component render time, check for N+1 fetches
└── Backend / API
    ├── Single endpoint slow? --> Profile database queries, check indexes
    ├── All endpoints slow? --> Check connection pool, memory, CPU
    └── Intermittent slowness? --> Check for lock contention, GC pauses, external deps

Step 2: Identify Bottlenecks

Common bottlenecks by category:

Frontend:

Symptom Possible Cause Where to Investigate
Slow LCP Oversized images, render-blocking resources, slow server Check network waterfall, image dimensions
High CLS Images without dimensions, late-loading content, font shifts Check layout shift attribution
Poor INP Too-heavy JavaScript on the main thread, large DOM updates Check long tasks in the Performance trace
Slow initial load Oversized bundle, too many network requests Check bundle size, code splitting

Backend:

Symptom Possible Cause Where to Investigate
Slow API response N+1 queries, missing indexes, unoptimized queries Check database query logs
Memory growth Leaked references, unbounded caches, large payloads Heap snapshot analysis
CPU spikes Synchronous heavy computation, regex backtracking CPU profiling
High latency Missing caching, redundant computation, network hops Trace the request along the call stack

Step 3: Fix Common Anti-Patterns

N+1 Queries (Backend)

// BAD: N+1 — one query per task for the owner
const tasks = await db.tasks.findMany();
for (const task of tasks) {
  task.owner = await db.users.findUnique({ where: { id: task.ownerId } });
}

// GOOD: Single query with join/include
const tasks = await db.tasks.findMany({
  include: { owner: true },
});

Unbounded Data Fetching

// BAD: Fetching all records
const allTasks = await db.tasks.findMany();

// GOOD: Paginated with limits
const tasks = await db.tasks.findMany({
  take: 20,
  skip: (page - 1) * 20,
  orderBy: { createdAt: 'desc' },
});

Queries That Ignore Their Index

"Add an index" is a guess. The query plan is the measurement:

EXPLAIN ANALYZE
SELECT id, title FROM tasks
WHERE owner_id = 42 ORDER BY created_at DESC LIMIT 20;

Three things in the output determine how to fix it:

What you see What it means
appears on a large table where you expected an index Seq Scan there is no usable index for this predicate
estimated rows= differs from actual by an order of magnitude statistics are stale; the planner makes choices based on wrong information
above the scan there is a Sort node the index covers the filter condition but does not cover ORDER BY

Build indexes according tothe shape of the query, not by looking at a single column in isolation. In a composite index, equality columns come first, range or sort columns come after:

CREATE INDEX idx_tasks_owner_created ON tasks (owner_id, created_at DESC);

Cases where an index cannot help:

Case Reason
low selectivity, the query is dominated by a value (a active column where 95% are status , and the filter condition is exactly active) a sequential scan really is cheaper, and the planner will ignore the index. Conversely, when filtering for rare values the conclusion is the opposite, and a partial index is very suitable here
leading wildcard (LIKE '%term') a B-tree cannot seek without a prefix; needs trigram or full-text
there is a function on the column (WHERE lower(email) = ?) a plain column index is unusable; instead build an index on that expression
write-heavy tables every index is a tax on every INSERT/UPDATE ; you need to measure the write cost, not just look at the read benefit

rerun once after EXPLAIN ANALYZE. An index that did not change the execution plan should be reverted (Step 4), and it is not free: it pays a cost on every write.

Connection Pool Exhaustion

The Signature is very distinctive:all endpoints slow down at the same time, and the slow time is spent waiting for connections rather than executing, while the database reports a large number of idle sessions.

// BAD: a pool per request or per module — under serverless this multiplies
// by instance count and exhausts the database's connection limit
// GOOD: one pool per process, sized against the database's ceiling
const pool = new Pool({
  max: 10,                        // instances × max must stay under max_connections
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000, // fail fast instead of queueing forever
});

Bigger does not mean faster. A pool larger than the number the database can actually execute concurrently just moves the queue from your application into the database — where it is harder to see. When the number of instances is unbounded (serverless, autoscaling), the solution is a proxy that does connection reuse (pgbouncer, RDS Proxy), not turning up max。

Missing Image Optimization (frontend)

<!-- BAD: No dimensions, no format optimization -->
<img src="/hero.jpg" />

<!-- GOOD: Hero / LCP image — art direction + resolution switching, high priority -->
<!--
  Two techniques combined:
  - Art direction (media): different crop/composition per breakpoint
  - Resolution switching (srcset + sizes): right file size per screen density
-->
<picture>
  <!-- Mobile: portrait crop (8:10) -->
  <source
    media="(max-width: 767px)"
    srcset="/hero-mobile-400.avif 400w, /hero-mobile-800.avif 800w"
    sizes="100vw"
    width="800"
    height="1000"
    type="image/avif"
  />
  <source
    media="(max-width: 767px)"
    srcset="/hero-mobile-400.webp 400w, /hero-mobile-800.webp 800w"
    sizes="100vw"
    width="800"
    height="1000"
    type="image/webp"
  />
  <!-- Desktop: landscape crop (2:1) -->
  <source
    srcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-1600.avif 1600w"
    sizes="(max-width: 1200px) 100vw, 1200px"
    width="1200"
    height="600"
    type="image/avif"
  />
  <source
    srcset="/hero-800.webp 800w, /hero-1200.webp 1200w, /hero-1600.webp 1600w"
    sizes="(max-width: 1200px) 100vw, 1200px"
    width="1200"
    height="600"
    type="image/webp"
  />
  <img
    src="/hero-desktop.jpg"
    width="1200"
    height="600"
    fetchpriority="high"
    alt="Hero image description"
  />
</picture>

<!-- GOOD: Below-the-fold image — lazy loaded + async decoding -->
<img
  src="/content.webp"
  width="800"
  height="400"
  loading="lazy"
  decoding="async"
  alt="Content image description"
/>

Unnecessary Re-renders (React)

// BAD: Creates new object on every render, causing children to re-render
function TaskList() {
  return <TaskFilters options={{ sortBy: 'date', order: 'desc' }} />;
}

// GOOD: Stable reference
const DEFAULT_OPTIONS = { sortBy: 'date', order: 'desc' } as const;
function TaskList() {
  return <TaskFilters options={DEFAULT_OPTIONS} />;
}

// Use React.memo for expensive components
const TaskItem = React.memo(function TaskItem({ task }: Props) {
  return <div>{/* expensive render */}</div>;
});

// Use useMemo for expensive computations
function TaskStats({ tasks }: Props) {
  const stats = useMemo(() => calculateStats(tasks), [tasks]);
  return <div>{stats.completed} / {stats.total}</div>;
}

Large Bundle Size

// Modern bundlers (Vite, webpack 5+) handle named imports with tree-shaking automatically,
// provided the dependency ships ESM and is marked `sideEffects: false` in package.json.
// Profile before changing import styles — the real gains come from splitting and lazy loading.

// GOOD: Dynamic import for heavy, rarely-used features
const ChartLibrary = lazy(() => import('./ChartLibrary'));

// GOOD: Route-level code splitting wrapped in Suspense
const SettingsPage = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <SettingsPage />
    </Suspense>
  );
}

Missing Caching (backend)

Cache things that are expensive to produce and are read far more often than they change. Caching a query that is already fast buys you nothing but an extra network hop, a staleness bug, and an eviction policy to maintain.

Choose the layer deliberately:

Layer Visible to whom When to use Cost
In-process (Map、LRU) a single instance data is small and hot, and per-instance staleness is acceptable each instance drifts on its own; invalidation can only reach one instance
Shared (Redis, Memcached) all instances instances must be consistent with each other, or recomputing the value is very expensive one network hop, plus a service to run and monitor
CDN / edge everyone, by URL the response is public and is identical for a given key Invalidation is the hard part; assume you cannot quickly retract a bad response
// Cache frequently-read, rarely-changed data
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
let cachedConfig: AppConfig | null = null;
let cacheExpiry = 0;

async function getAppConfig(): Promise<AppConfig> {
  if (cachedConfig && Date.now() < cacheExpiry) {
    return cachedConfig;
  }
  cachedConfig = await db.config.findFirst();
  cacheExpiry = Date.now() + CACHE_TTL;
  return cachedConfig;
}

// HTTP caching headers for static assets
app.use('/static', express.static('public', {
  maxAge: '1y',           // Cache for 1 year
  immutable: true,        // Never revalidate (use content hashing in filenames)
}));

// Cache-Control for API responses
res.set('Cache-Control', 'public, max-age=300'); // 5 minutes

Key design determines correctness. Every input that changes the response must go into the key: tenant, locale, permissions, feature flags. A key that omits the viewer is how User A's data gets sent to User B — and this kind of incident often ships under the guise of a "performance improvement."

Pick one invalidation strategy, don't use all three:

Strategy Trade-off
TTL Simplest. You accept at most one TTL of staleness, so state the acceptable time window explicitly
Event- or tag-based Fresh on write, but the writer must from then on understand the cache topology
Versioned keys (user:42:profile:v7) Never invalidate, just stop reading the old key. Occupies memory until evicted

Guard against stampedes. A hot key expires, all concurrent requests miss together, and origin instantly takes the full load — this is how a cache goes from "preventing incidents" to "causing incidents." Keep serving stale data while a single request recomputes (stale-while-revalidate), or coalesce concurrent misses behind one in-flight promise so that N waiters trigger only one recomputation.

Don't cache: anything where staleness would be a correctness bug (balances, permissions, inventory at checkout), or per-user data placed under a key that can't identify the user. For request coalescing, write policies, negative caching, and the cache checklist, see ../../references/performance-checklist.md。

Step 4: Verify (keep or revert)

Before re-measuring, a fix is just a hypothesis. This step decides whether it survives.

Re-measure the same way you established the baseline: the same commands, the same conditions, the same fixed budget (wall-clock, sample count, or request count). Comparing a cold-cache baseline against hot-cache results measures the cache, not your change.

Change only one thing at a time. Three optimizations landing together produce only one number, and you can't attribute it. If they must ship together, measure each one separately first.

Beat the noise, not just the mean. Repeat the measurement and compare the delta against run-to-run variance. A 3% improvement within ±5% variance is not an improvement, just a different sample.

Then decide strictly:

Result relative to baseline Action
Crosses the threshold, all tests green Keep. Write the before and after numbers in the commit message.
Within noise (no measurable change) Revert。
Worse Revert。
Improved, but some tests turn red Revert。 This is a regression wearing the clothes of a victory.

"Neutral" is a revert, not a keep. This is the step teams skip most often: the code is already written, throwing it away feels wasteful, so it lands unmeasured, and the codebase accumulates complexity that never bought anything. Code you keep, you maintain for life. Make it earn its keep.

Correctness gates the metrics. The test suite stays all green,andthe numbers change. An "optimization" that "wins" by cutting work the product requires (skipping a validation, caching something that must be fresh, deleting an await) is a regression, not a win.

Record every attempt, including the ones that were reverted

Reverted work leaves no trace in git history, and that is exactly why the same dead idea gets tried again next quarter. Keep a short ledger so discarded ideas stay discarded:

Idea Baseline → Result Conclusion Reason
Add memoize to the row component INP 240ms → 235ms reverted Within noise (±15ms). The bottleneck is not in the rows.
List virtualization INP 240ms → 90ms kept The long tasks in the trace disappeared.
Preconnect to the API origin LCP 2.8s → 2.8s reverted It was already same-origin.

Write it in a subsection of the PR description, or put a PERF.mdin the repo; either works. What matters is that the next person (or next agent) reads it before proposing an experiment, instead of rerunning an experiment that has already failed.

Step 5: Prevent regressions

Guard the metric users actually feel, not every number you can get. Use the same LCP, INP, p95 latency, or other primary metric that originally proved this fix was worth it.

When the interface is user-facing, use two complementary layers of guards:

  • Synthetic CI gate: Use a performance budget to catch reproducible regressions before merge. Repeat-sample noisy measurements, or compare medians/trends, so normal run-to-run variance does not turn the gate into a flaky check.
  • Field monitoring: Alert on meaningful p75 shifts in RUM data. Use attributed web-vitals data to locate the cause; treat CrUX's rolling window as a confirmation signal, not an instant alert.

When either guard fires, go back to Step 1 and re-establish the baseline before proposing the next fix.

Set budgets and enforce them:

JavaScript bundle: < 200KB gzipped (initial load)
CSS: < 50KB gzipped
Images: < 200KB per image (above the fold)
Fonts: < 100KB total
API response time: < 200ms (p95)
Time to Interactive: < 3.5s on 4G
Lighthouse Performance score: ≥ 90

Enforce in CI:

# Bundle size check
npx bundlesize --config bundlesize.config.json

# Lighthouse CI
npx lhci autorun

See also

For detailed performance checklists, optimization commands, and anti-pattern references, see ../../references/performance-checklist.md。

Common rationalizations

Self-rationalization Reality
"We'll optimize later" Performance debt compounds. Fix obvious anti-patterns now; micro-optimizations can wait.
"It's fast on my machine" Your machine is not the user's machine. Profile on representative hardware and networks.
"This optimization is obvious" If you haven't measured it, you don't know. Profile first.
"Users won't notice 100ms" Research shows 100ms of latency affects conversion rates. Users are more sensitive than you think.
"The framework will handle performance" Frameworks prevent some problems, but they can't fix N+1 queries or an oversized bundle.
"The query is slow, add an index" Look at the execution plan first. The index may already exist and be unusable, and every index permanently taxes writes.
"Just cache it" Caching an already cheap call buys nothing and adds a staleness bug. Only cache things that are both expensive andandread repeatedly far more than they are written.
"If the connection pool isn't enough, just make it bigger" A pool larger than the database service can handle just moves the queue somewhere less visible. Find out who is holding the connections.
"Not much effect, but no harm either" A neutral change should be reverted. You will pay maintenance costs forever for it, and get nothing in return.
"The code is already written, might as well keep it" Sunk cost. Measurement does not care how long the change took to write at the time.
"The improvement is obvious, no need to measure again" Then re-measuring once is cheap and can prove it. Unmeasured "wins" are exactly how neutral complexity gets in.

Red flags

  • Optimizing without profiling data to back it up
  • N+1 query patterns appearing in data fetching
  • Adding an index without before-and-after query plans as evidence
  • A cache key missing some input the response depends on (tenant, locale, viewer)
  • A cache with no declared staleness window and no invalidation strategy
  • Increasing the connection pool because of connection exhaustion, without finding out who is holding the connections
  • List endpoints with no pagination
  • Images with no dimensions, lazy loading, or responsive sizes
  • Bundle size growing with nobody reviewing it
  • No performance monitoring in production
  • Stuffed everywhere with React.memo and useMemo(overuse is as bad as no use)
  • Keeping an optimization without a re-measurement that can prove it
  • Multiple optimizations bundled into the same measurement, so none of the changes can be attributed
  • A "win" requires modifying, skipping, or deleting a test
  • The same failed optimization tried over and over because nobody recorded the first attempt

Verification

After any performance-related change:

  • There is a before-and-after measurement (concrete numbers)
  • The result is re-measured the same way as the baseline (same command, same conditions)
  • The improvement exceeds run-to-run variance, not just the mean
  • Changes that did not beat the baseline are reverted, rather than kept as "neutral"
  • Every attempt is recorded; kept and reverted alike, so dead ideas are not re-run
  • The specific bottleneck has been identified and addressed
  • Core Web Vitals are within the "Good" threshold
  • Bundle size has not grown significantly
  • There are no N+1 queries in new data-fetching code
  • Every new index has before-and-after query plans as evidence, and its write cost has been considered
  • Every new cache explains how the key is composed and how staleness is handled
  • The user-facing metrics being measured have a synthetic budget or field monitor that can detect regressions
  • Existing tests still pass (the optimization did not break behavior)