| name | performance |
| description | Use when a page or interaction is slow for one user and the fix starts from a measurement โ a failing Core Web Vital (LCP, INP, CLS), a heavy JS bundle, wasted re-renders, an over-broad client boundary: profile, attribute the cost to one phase, fix it, re-measure. NOT surviving concurrent load (that is `scaling`), NOT the slow query (that is `postgresdb`). |
| tags | ["performance","core-web-vitals","lcp","inp","cls","bundle-size","profiling","react","web-vitals"] |
| recommends | ["scaling","postgresdb","redis","nextjs","react","observability","monitoring"] |
| profiles | [] |
| origin | risco |
Performance: make one thing fast
Performance makes a single request, page, or interaction faster for one user. Scaling makes many requests survive at the same time. Different problem, different toolbox โ if the system is fine for you and only bends under concurrent load, that is ../scaling/SKILL.md, not this.
The whole job in one line: measure with a profiler โ attribute the cost to one specific phase โ apply the one fix that moves that phase โ re-measure against a number. Repeat until you clear the threshold, then stop.
Prime directive: no fix without a profile first. A waterfall, a flame chart, a bundle treemap, or a field CWV number comes before you touch code. The fix you guess is almost never the fix the measurement points at, and a "fast" change that moves a phase nobody was waiting on is wasted work that you then have to maintain.
Cross-reference, don't duplicate: the last column of the table below names where each deep fix lives. The one case it has no row for โ you decided what to cache to cut a phase and now need that cache race-free โ is ../redis/SKILL.md.
Decision table โ symptom to first move
| Symptom | Metric / phase | First probe | First lever | Deep fix lives in |
|---|
| Slow load, hero appears late | LCP | Lighthouse + DevTools trace | attribute to a subpart, then fix that one | this skill / ../postgresdb/SKILL.md if TTFB |
| Janky scroll, laggy typing/click | INP | DevTools Performance trace (long-task bars) | break the >50 ms task, yield | this skill |
| Layout jumps as it loads | CLS | Lighthouse layout-shift trace | reserve space (dimensions / aspect-ratio) | this skill |
| "First Load JS is huge" | bundle bytes | @next/bundle-analyzer / source-map-explorer | kill/replace the heavy dep, then dynamic import | this skill |
| Component re-renders constantly | render time | React DevTools Profiler ("why did this render") | fix the unstable ref / boundary | ../react/SKILL.md for the pattern |
'use client' drags server work down | First Load JS | bundle analyzer server/client trace | push the boundary to a leaf | ../nextjs/SKILL.md |
| Slow only under concurrent load | throughput, not latency | load test | โ not this skill โ | ../scaling/SKILL.md |
| TTFB dominated by one query | server time | query plan / EXPLAIN | โ not this skill โ | ../postgresdb/SKILL.md |
Step 0 โ measure first: field vs lab
You have two kinds of measurement and they answer different questions. Use both, in this order.
- Field data (CrUX, RUM via the
web-vitals library) at the 75th percentile tells you what real users actually feel. A metric passes when โฅ75% of page views hit "good". This is the only number that matters for "is it actually a problem".
- Lab data (Lighthouse, the Chrome DevTools Performance trace) tells you why โ it reproduces one load under controlled conditions so you can read the waterfall and flame chart.
Rule: never optimize a lab number that no real user ever hits, and never report the mean โ report the 75th percentile. A lab Lighthouse run on a throttled cold cache can show an LCP your field p75 never sees; chasing it burns time on a phantom. Conversely a field INP failure with a clean lab run means you have not reproduced the slow interaction yet โ go find it.
The output of Step 0 is one sentence naming the bottleneck: "LCP is 3.8 s and 2.1 s of it is resource load delay because the hero image is lazy-loaded." Do not proceed without that sentence. Tool runbooks (Lighthouse CI flags, reading a flame chart, the Profiler workflow, wiring web-vitals for RUM) live in references/profiling-playbook.md.
Measurement honesty: static source can identify a plausible bottleneck, never a measured LCP/INP/CLS, latency win or byte saving. If runtime/field data is unavailable, label the finding POTENTIAL, name the exact trace/benchmark needed, and make no numeric improvement claim. Do not turn code inspection into fictional telemetry.
Keep one experiment card per change:
metric + conditions | baseline distribution | one hypothesis/change | after distribution | keep/revert | next
Use the same device, data volume, cache state, build mode and sampling method before and after. Repeat enough runs to see ordinary variance; a one-off result inside that noise is neutral. Keep a change only when it moves the target beyond the noise without violating another budget. Revert a neutral or worse experiment with a targeted edit and preserve the card โ failed attempts prevent the next person from paying for the same guess.
CWV "good" thresholds (field p75)
| Metric | Good | Needs improvement | Poor |
|---|
| LCP | โค 2.5 s | 2.5โ4.0 s | > 4.0 s |
| INP | โค 200 ms | 200โ500 ms | > 500 ms |
| CLS | โค 0.1 | 0.1โ0.25 | > 0.25 |
INP replaced FID as a Core Web Vital in March 2024 and is still a CWV โ it measures the full interaction-to-next-paint, so a fast event handler with a slow paint still fails. As of 2025, ~62% of mobile pages hit good LCP (up from 44% in 2022), and LCP remains the hardest CWV to pass and the most common overall bottleneck โ so start there when the symptom is "slow load".
LCP โ attribute to a subpart before fixing
LCP is not one thing. It decomposes into four sequential phases, and each phase has a different fix. Attribute first, then fix only the dominant phase.
| Subpart | What it is | The fix that moves it |
|---|
| TTFB | server + network to first byte | server work โ punt to ../postgresdb/SKILL.md (slow query) or ../scaling/SKILL.md (under load); cache the response |
| Resource load delay | gap between TTFB and the LCP resource starting to load | discoverability โ <link rel="preload">, fetchpriority="high", never lazy-load the hero |
| Resource load duration | time to actually download the LCP resource | smaller/next-gen image, CDN, right dimensions, priority on next/image |
| Element render delay | resource downloaded but not yet painted | unblock the main thread / render-blocking CSS/JS, font readiness |
A text-node LCP rendered in a system font has zero load delay and zero load duration โ its budget is all TTFB and render delay, so don't go hunting for an image to preload.
The single most common LCP mistake is lazy-loading the largest element. Make it eager and high priority:
<img src="/hero.webp" loading="lazy" alt="Product" />
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />
<img src="/hero.webp" fetchpriority="high" width="1200" height="630" alt="Product" />
import Image from "next/image";
<Image src="/hero.webp" width={1200} height={630} priority alt="Product" />;
INP โ kill the long task
INP is dominated by long tasks: any main-thread task over 50 ms blocks every interaction for its full duration. The fix is to break long work into chunks under 50 ms and yield to the main thread between them so a queued click or keypress can run.
function processAll(items: Item[]) {
for (const item of items) heavyWork(item);
}
async function processAll(items: Item[]) {
for (let i = 0; i < items.length; i++) {
heavyWork(items[i]);
if (i % 50 === 0 && "scheduler" in globalThis) {
await (globalThis as any).scheduler.yield();
}
}
}
Other INP levers, in order of payoff:
- Move third-party JS off the main thread (analytics, chat widgets, tag managers) โ defer, load on interaction, or sandbox in a worker. Third-party scripts are the usual hidden long-task source.
- Defer non-urgent handler work. Do the visible state update now; push the expensive recompute behind
requestIdleCallback, a debounce, or startTransition.
- Measure the actual interaction โ the field INP failure names which interaction; reproduce it in a DevTools trace and read the long-task bar, don't guess.
CLS โ reserve the space
CLS comes from space that was not reserved before content arrived: images/video/ads/embeds without dimensions, content injected above existing content, and font swaps (FOUT). Fix by reserving the box up front.
<img src="/card.webp" alt="" />
<img src="/card.webp" width="400" height="300" alt="" />
.media { aspect-ratio: 16 / 9; }
@font-face { font-family: Inter; font-display: optional; size-adjust: 100%; }
Rules: give every image/embed explicit width/height or an aspect-ratio; render skeleton boxes the size of the real content; never insert banners/notices above content the user is already reading (push them below or overlay); use font-display: optional (or swap + size-adjust) so a font swap doesn't reflow text.
Bundle size โ analyze before you cut
Never guess what's heavy. Generate a treemap, read it, then cut the biggest box.
ANALYZE=true npm run build
npx source-map-explorer 'dist/**/*.js'
Levers in order of payoff:
- Kill or replace the heavy dependency. The treemap usually shows one or two boxes dwarfing everything (a date library, an icon set, a charting lib). Replace with a lighter one or a few lines of your own.
- Dynamic
import() for non-critical UI. Modals, editors, charts, anything below the fold or behind an interaction loads on demand instead of in First Load JS.
- Fix tree-shaking โ stop importing the whole library for one function.
import _ from "lodash" pulls the entire library; import the one function (or use the native equivalent).
import _ from "lodash";
const fn = _.debounce(save, 300);
import debounce from "lodash-es/debounce";
const fn = debounce(save, 300);
import { RichEditor } from "@org/rich-editor";
import dynamic from "next/dynamic";
const RichEditor = dynamic(() => import("@org/rich-editor"), { ssr: false });
Render cost โ React 19, the compiler, and the boundary
React 19 with the React Compiler auto-memoizes, so blanket defensive useMemo/useCallback/memo is now an anti-pattern, not a best practice โ it adds noise and the compiler already handles the common cases. Manual memo is the exception, and you justify it with a profile.
- Profile with the React DevTools Profiler: record an interaction, find the component that re-rendered, read "why did this render" and its render time.
- The 16 ms rule: one frame at 60 fps is ~16 ms. If a manual memo saves less than that, skip it โ you can't see it and you're paying for the cache.
- The compiler cannot fix a new object/array/function reference created in render and passed as a prop โ that's a fresh identity every time. Hoist it or derive it stably; that's the real fix, not wrapping the consumer in
memo.
RSC boundary discipline. React Server Components ship zero JS for non-interactive trees; teams report 50โ70% First-Load-JS cuts from full RSC adoption. The 'use client' directive is a boundary: everything imported below it becomes client code. So push interactivity into the smallest leaf component and keep data fetching and layout on the server.
"use client";
export default function ProductPage({ data }) {
}
export default function ProductPage({ data }) {
return (
<article>
<ProductDetails data={data} /> {/* server */}
<AddToCartButton id={data.id} /> {/* the only 'use client' file */}
</article>
);
}
Use streaming SSR with <Suspense> to flush the static shell immediately and stream slow subtrees in parallel โ multiple independent boundaries stream concurrently, cutting time-to-first-paint when one section is data-bound. The canonical App-Router data and component patterns are ../nextjs/SKILL.md and ../react/SKILL.md; bring the perf loop here, take the idioms there.
Anti-patterns
| Anti-pattern | Why it's wrong | Do instead |
|---|
| Optimizing on a hunch, no profile | you fix a phase nobody waits on | measure โ attribute โ fix the dominant phase |
| Chasing a lab score no real user hits | lab cold-cache LCP โ field p75 | optimize the field 75th-percentile number |
| Reporting the mean, not p75 | the average hides the tail users feel | always read/report the 75th percentile |
Blanket useMemo/useCallback in React 19 | the compiler already memoizes; you add noise | memo only what the Profiler proves >16 ms |
| Lazy-loading the LCP / hero image | you defer the very element LCP measures | priority / fetchpriority=high, eager |
import _ from "lodash" for one function | the whole library lands in First Load JS | named import or a few lines of your own |
'use client' at the top of the tree | the entire subtree ships to the browser | push the boundary to the interactive leaf |
| Images/embeds with no reserved space | content reflows on load โ CLS | explicit width/height or aspect-ratio |
| Micro-optimizing a non-bottleneck phase | effort moves a number off the critical path | fix only the phase the trace points at |
| "Done" without re-measuring | the fix may not have moved the metric | re-measure against the threshold, then stop |
| Reporting source-only review as a measured win | no trace/field/benchmark ran, so the number is invented | label POTENTIAL and name the measurement |
| Changing three levers in one experiment | you cannot attribute the result or reuse the learning | one hypothesis/change per card, then re-measure |
| Keeping a neutral optimization | complexity rose but the target did not beat normal variance | targeted revert; retain the failed-experiment record |
Stop rule
You are done when the field 75th percentile clears the "good" threshold (LCP โค 2.5 s, INP โค 200 ms, CLS โค 0.1) and your bundle is under its budget โ re-measure to confirm, then stop. Going from "good" to "slightly better good" is gold-plating: it costs maintenance and moves a number no user notices. Commit the threshold as a budget so it can't silently regress (scripts/verify.sh lints that budget; see references/profiling-playbook.md for the field-RUM wiring that keeps the p75 honest in production).