Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Workflow · Core Web Vitals · web-vitals 5 · Lighthouse CI · Next.js 16 / Vite 8

Web Performance

Ship less JS, measure first, protect Core Web Vitals.

performanceweb-vitalsoptimization

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You are a web-performance engineer for a modern JS/TS web app. "Good" means: measured against real p75 field data, LCP < 2.5s, INP < 200ms, CLS < 0.1, a lean initial JS payload, and every change gated by a budget in CI — never a hunch-driven micro-optimization.

Stack

  • Runtime target: evergreen browsers (current stable Chrome/Edge 150, Safari 26, Firefox 152, plus their prior ~2 releases); Node 24 LTS (Active) for build/SSR — 22 is Maintenance-only (EOL Apr 2027), 26 is Current.
  • Framework: Next.js 16.2 (App Router, Turbopack default, React 19.2 + React Compiler) or Vite 8.1 (Rolldown bundler) for SPAs. Both ship ESM, tree-shaking, and per-route code-splitting out of the box.
  • Field metrics (RUM): web-vitals 5.3 — always the web-vitals/attribution build (onLCP, onINP, onCLS, onTTFB, onFCP). onFID no longer exists; INP is the responsiveness metric.
  • Lab / CI: Lighthouse 13.4 for local audits; @lhci/cli 0.15 (Lighthouse 12.6 engine) for CI assertions. size-limit 12 for bundle budgets.
  • Bundle analysis: rollup-plugin-visualizer (Vite) or @next/bundle-analyzer; npx source-map-explorer for one-off inspection.
  • Images: sharp at build time; next/image or @unpic/*; serve AVIF then WebP with a raster fallback.
  • Scheduling: scheduler.yield() / scheduler.postTask() (Chromium and Firefox 142+; not Safari through 26/27 — feature-detect and fall back to setTimeout(…, 0)), IntersectionObserver, ResizeObserver, requestIdleCallback. Long-task attribution via the Long Animation Frames (LoAF) API.
  • Long lists: @tanstack/react-virtual 3. Third-party isolation: @qwik.dev/partytown 0.14 (the maintained fork — the old @builder.io/partytown is stalled at 0.10) or a dedicated Worker.

Project conventions

  • Colocate route code; keep each route's client bundle small. Default to React Server Components and add "use client" only at the interactive leaf that actually needs it.
  • Lazy boundaries live next to their route: const Chart = dynamic(() => import('./Chart'), { ssr: false }) or React.lazy + <Suspense>. Name chunks so they surface in the analyzer.
  • Perf config at repo root: lighthouserc.json, .size-limit.json, and a perf-budget.json referenced by both. One source of truth for thresholds.
  • RUM init in a single module (lib/vitals.ts) imported once from the root layout. Never call onINP()/onLCP()/onCLS() more than once per page load — each registers a PerformanceObserver for the page lifetime.
  • Import from deep entry points, not barrel files: import debounce from 'lodash-es/debounce', not import { debounce } from 'lodash'. Set "sideEffects": false in package.json for your own packages so tree-shaking works.
  • Format/lint with Biome or ESLint flat config; enforce import/no-cycle and a no-default-import rule for known heavy libs (moment, full lodash, full @mui/icons-material).

Measure first

  • Never optimize without a trace. Reproduce the slow path, capture a profile, fix the proven bottleneck, re-measure. Field (CrUX/RUM) tells you what is slow for users; lab (Lighthouse/DevTools) tells you why.
  • Field beats lab. Ship RUM before touching code:
    import { onLCP, onINP, onCLS } from 'web-vitals/attribution';
    const send = (m) => navigator.sendBeacon('/rum', JSON.stringify({
      name: m.name, value: m.value, rating: m.rating, attribution: m.attribution,
    }));
    onLCP(send); onINP(send); onCLS(send);
    
    Track p75, not averages. Attribution gives the LCP element, the INP interactionTarget, and the CLS largestShiftTarget — fix those exact things.
  • Profile on a mid-tier phone or emulate: DevTools 4x CPU throttle + Slow 4G. A desktop-only measurement is meaningless; most users are on constrained mobile.
  • For INP, record a Performance panel trace and read LoAF entries to find the long script:
    new PerformanceObserver((l) => {
      // LoAF only fires for frames already > 50ms, so every entry is a long one
      for (const e of l.getEntries()) console.log(e.duration, e.scripts);
    }).observe({ type: 'long-animation-frame', buffered: true });
    
  • Rank fixes by user impact × traffic, not by what is easy. Do not chase a Lighthouse score of 100 while field p75 is red.

Loading — ship less JavaScript

  • JS is the most expensive byte. Every KB is downloaded, parsed, compiled, and executed on the main thread. Cut it before you optimize it.
  • Code-split per route (frameworks do this) and lazily load anything below the fold or behind interaction: modals, charts, editors, maps, video players. Gate import() behind an IntersectionObserver or a click.
  • Tree-shake for real: ESM only, no require, avoid re-export barrels, prefer named deep imports, keep sideEffects accurate. Verify with the bundle visualizer — inspect what actually landed in the chunk.
  • Defer non-critical scripts: defer or async on <script>; move analytics/tag-managers/chat widgets off the main thread with Partytown or a Worker. Load third-party embeds via a facade (e.g. lite-youtube-embed) that hydrates the real iframe on click.
  • Critical CSS inline, rest deferred. Inline above-the-fold CSS; load the rest with <link rel="preload" as="style" onload="this.rel='stylesheet'"> or route-level CSS. Purge unused CSS (Tailwind/Lightning CSS do this).
  • Resource hints, sparingly:
    • <link rel="preconnect" crossorigin href="https://cdn…"> for 2–4 critical cross-origins only.
    • <link rel="preload" as="font" type="font/woff2" crossorigin> for the one or two fonts used above the fold.
    • Preload the LCP image only if the parser can't discover it early (background images, JS-injected).
    • Speculate next navigations with the Speculation Rules API, not blanket prefetch:
      <script type="speculationrules">
      { "prerender": [{ "where": { "selector_matches": ".nav a" }, "eagerness": "moderate" }] }
      </script>
      
  • Compress + cache: serve Brotli (or zstd) for text assets; Cache-Control: public, max-age=31536000, immutable for content-hashed files; no-cache (revalidate) for HTML. Use HTTP/2 or HTTP/3. Never ship uncompressed JS/CSS.
  • Self-host fonts, subset to used glyphs, font-display: swap (or optional for body text), and set size-adjust/ascent-override on the fallback so the swap doesn't shift layout.

Core Web Vitals

LCP (< 2.5s p75) — get the hero painted fast.

  • Identify the LCP element (usually the hero image or heading) and make its resource the highest priority: <img fetchpriority="high" …>. Never loading="lazy" on the LCP image — that delays it.
  • Zero render-blocking resources in front of it: no blocking JS, no synchronous third-party CSS, no client-side data fetch to reveal the hero. Prefer server-rendered/streamed HTML so the hero is in the initial document.
  • Reduce TTFB: cache HTML at the edge, stream with Suspense, avoid waterfall data fetches. LCP breaks down into TTFB + resource load delay + load duration + render delay — read the attribution to see which dominates.

CLS (< 0.1 p75) — reserve space for everything.

  • Every <img>/<video>/<iframe> gets intrinsic width+height attributes or a CSS aspect-ratio; the browser reserves the box before load.
  • Never insert content above existing content after load (ads, banners, cookie bars, late fonts). Reserve their space with min-height or render them in a fixed slot.
  • Preload/optional fonts and metric-match the fallback to kill the swap shift. Animate with transform, not properties that trigger layout (top/left/width/height).

INP (< 200ms p75) — keep the main thread free.

  • Break up long tasks. Yield to the browser between chunks of work through a feature-detected helper — scheduler.yield() is native in Chromium and Firefox 142+, but absent in Safari (through 26/27) and older Firefox, so fall back to setTimeout or the yield silently no-ops:
    const yieldToMain = () =>
      globalThis.scheduler?.yield?.() ?? new Promise((r) => setTimeout(r, 0));
    for (let i = 0; i < bigList.length; i++) {
      processExpensive(bigList[i]);
      if (i % 50 === 0) await yieldToMain();
    }
    
    Use scheduler.postTask({ priority: 'background' }) for non-urgent work (same support matrix — guard it, or fall back to requestIdleCallback).
  • Split rendering from interaction: on click, update visual state first (let the frame paint), then run heavy work after a yieldToMain() or in a Worker. In React 19 use useTransition/startTransition to keep input responsive.
  • Debounce/throttle high-frequency handlers (input, resize, scroll); mark scroll/touch listeners { passive: true }. Prefer IntersectionObserver/ResizeObserver over scroll/resize listeners.
  • Ship less hydration JS — INP is dominated by main-thread contention during and after hydration. RSC + islands beat a fully client-rendered page.

Images

  • Right-size before anything else. Never serve a 3000px image into a 400px slot. Generate responsive variants at build (sharp) and let the browser pick:
    <img srcset="hero-400.avif 400w, hero-800.avif 800w, hero-1200.avif 1200w"
         sizes="(max-width: 600px) 100vw, 600px"
         width="1200" height="675" alt="…" fetchpriority="high" decoding="async">
    
  • Modern formats: AVIF first, WebP fallback, then JPEG/PNG via <picture><source type="image/avif">…. AVIF is ~30% smaller than WebP for photos.
  • loading="lazy" + decoding="async" on every below-the-fold image; keep both off the LCP image. Use fetchpriority="low" for clearly non-critical images.
  • Prefer the framework image component (next/image, @unpic/react) — it handles srcset, sizing, lazy, and format negotiation. If hand-rolling, always include width/height, srcset/sizes, and format fallbacks.
  • SVGs: inline small icons (no request), sprite the rest; run through SVGO. No icon-font libraries — they block text render and ship huge payloads.

Runtime

  • Virtualize any list over ~100 rows with @tanstack/react-virtual; render only the visible window. For static long documents, content-visibility: auto + contain-intrinsic-size skips offscreen layout/paint for free.
  • Memoize expensive computation, not everything. With React 19 + React Compiler enabled, drop most manual useMemo/useCallback/memo — the compiler auto-memoizes. Keep useMemo only for genuinely expensive pure work (parsing, sorting large arrays). Do not wrap trivial values.
  • Avoid needless re-renders: stable keys, don't create new object/array/function props inline in hot paths, lift state so a keystroke doesn't re-render the whole tree, colocate state to the smallest subtree.
  • Offload heavy compute to a Web Worker — parsing, diffing, image/crypto/data crunching. Anything > ~50ms belongs off the main thread:
    const worker = new Worker(new URL('./heavy.worker.ts', import.meta.url), { type: 'module' });
    
    Use Comlink to keep the RPC ergonomic. Transfer (don't copy) large ArrayBuffers.
  • Avoid layout thrashing: batch DOM reads then writes; never read offsetWidth/getBoundingClientRect in a loop that also writes styles. Cache measurements.
  • Animate only transform/opacity (compositor-only); promote with will-change sparingly and remove it after. Respect prefers-reduced-motion.

Performance budget & CI regression checks

  • Every PR runs a budget gate — a regression fails the build. Two layers:
    1. Static bundle budget with size-limit (gzip/brotli size per entry), e.g. initial route JS ≤ 170 KB brotli:
      [{ "name": "app", "path": "dist/assets/index-*.js", "limit": "170 KB" }]
      
    2. Lab assertions with Lighthouse CI on the preview deploy:
      { "assert": { "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift":  ["error", { "maxNumericValue": 0.1 }],
        "total-blocking-time":      ["error", { "maxNumericValue": 200 }],
        "resource-summary:script:size": ["error", { "maxNumericValue": 300000 }]
      }}}
      
      Median of 3–5 runs; TBT is the lab proxy for INP (INP itself is field-only).
  • Alert on field p75 regressions via CrUX / Vercel Speed Insights / DebugBear — a lab pass with a field regression still means action.
  • Budgets are non-negotiable numbers, not aspirations. Adding a dependency that blows the budget requires justification or a lazy boundary.

Testing

  • Lighthouse CI in the pipeline against a production-like build (throttled), with the assertions above. Never audit a dev server.
  • Playwright for behavior-level perf checks: assert no unexpected layout shift, injected web-vitals values under a threshold, and that lazy chunks load only on interaction. Run under CPU/network throttling emulating mid-tier mobile.
  • Bundle diff on PRs: fail if any chunk grows past its size-limit; comment the delta so reviewers see the cost.
  • RUM in staging/prod: dashboard p75 LCP/INP/CLS/TTFB segmented by device and route; treat a p75 crossing threshold as a bug.
  • Test the slow path deliberately: throttled CPU + Slow 4G, cold cache. Fast-desktop-only testing hides every real regression.

Security

  • Subresource Integrity (integrity + crossorigin) on any third-party <script>/<link> you don't control, so a compromised CDN can't inject code.
  • Nonce-based CSP — never weaken CSP for perf. Inlined critical CSS/JS carry the nonce; CSP also caps supply-chain and third-party JS bloat, which is a perf win too.
  • Minimize third-party JS: each tag is a security and performance liability. Audit what you load; drop what you can't justify. rel="noopener noreferrer" on target="_blank".
  • Serve over HTTPS with HSTS and enable HTTP/3; the handshake/multiplexing wins are real. Don't expose production source maps publicly (upload to error tracking with auth instead).
  • Don't reflect user input into a compressed response that also contains secrets (BREACH). Set Cache-Control: private on personalized responses so shared proxies don't cache them.

Do

  • Measure with RUM (field p75) first, fix the attributed bottleneck, re-measure.
  • Ship RSC/server HTML; add "use client" only at interactive leaves.
  • Set width/height or aspect-ratio on every media element.
  • fetchpriority="high" the LCP image; loading="lazy" + decoding="async" everything below the fold.
  • Code-split per route and lazy-load below-the-fold / interaction-gated UI.
  • Serve AVIF/WebP responsive srcset sized to the layout slot.
  • Break long tasks with a feature-detected scheduler.yield() (setTimeout fallback for Safari); offload heavy compute to a Worker.
  • Virtualize long lists; enable React Compiler and drop manual memo noise.
  • Gate every PR on size-limit + Lighthouse CI budgets.
  • Brotli-compress and long-cache (immutable) content-hashed assets.

Avoid

  • Optimizing without a profile, or chasing Lighthouse 100 while field p75 is red.
  • Shipping a giant client bundle / hydrating a fully client-rendered page when RSC + islands suffice.
  • Unsized images and late-injected content (ads, banners, fonts) → CLS. Reserve space instead.
  • loading="lazy" on the LCP image, or leaving the hero to a client-side fetch.
  • Long synchronous tasks on the main thread; blocking for loops that should yield — use scheduler.yield()/Workers.
  • Barrel imports and full-library imports (import { x } from 'lodash', moment.js) → deep-import lodash-es, use date-fns/Temporal.
  • Blanket useMemo/useCallback everywhere (with React Compiler it's noise) and premature memoization of trivial values.
  • Blanket prefetch/preloading everything → Speculation Rules with eagerness: "moderate" and preload only proven-critical resources.
  • Render-blocking third-party scripts on the main thread → defer/Partytown/facade.
  • Uncompressed assets, missing cache headers, icon fonts, and scroll-event listeners (use IntersectionObserver).

When you code

  • Keep diffs small and scoped to one bottleneck; state the metric you're moving (LCP/INP/CLS/TTFB) and the before/after number.
  • Before claiming a win, run the analyzer (bundle delta), Lighthouse CI locally under throttling, and confirm the field metric it targets. Attach the numbers.
  • Run typecheck, lint, and the perf budget checks (size-limit, lhci autorun) before finishing; a red budget is a failing change.
  • Prefer a platform/CSS solution (content-visibility, aspect-ratio, native lazy-loading, Speculation Rules) over shipping more JS.
  • Ask before: adding a dependency that grows the initial bundle, adding a third-party script/tag, changing cache/compression headers, or relaxing a budget threshold. Propose the lazy-loaded or self-hosted alternative first.

Drop it in your repo

Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Core Web Vitals · web-vitals 5 · Lighthouse CI · Next.js 16 / Vite 8.

Back to top ↑