| applyTo | ** |
|---|---|
| description | Comprehensive web performance standards based on Core Web Vitals (LCP, INP, CLS), with 50+ anti-patterns, detection regex, framework-specific fixes for modern web frameworks, and modern API guidance. |
Comprehensive performance rules for web application development. Every anti-pattern includes a severity classification, detection method, Core Web Vitals metric impacted, and corrective code examples.
Severity levels:
- CRITICAL — Directly degrades a Core Web Vital past the "poor" threshold. Must be fixed before merge.
- IMPORTANT — Measurably impacts user experience. Fix in same sprint.
- SUGGESTION — Optimization opportunity. Plan for a future iteration.
Good: < 2.5s | Needs Improvement: 2.5-4s | Poor: > 4s
Measures when the largest visible content element finishes rendering. Four sequential phases:
| Phase | Target | What It Measures |
|---|---|---|
| TTFB | ~40% of budget | Server response time |
| Resource Load Delay | < 10% | Time between TTFB and LCP resource fetch start |
| Resource Load Duration | ~40% | Download time for the LCP resource |
| Element Render Delay | < 10% | Time between download and paint |
Good: < 200ms | Needs Improvement: 200-500ms | Poor: > 500ms
Measures latency of all user interactions, reports the worst. Three phases:
| Phase | Optimization |
|---|---|
| Input Delay | Break long tasks, yield to browser |
| Processing Time | Keep handlers < 50ms |
| Presentation Delay | Minimize DOM size, avoid forced layout |
Diagnostic tool: Use the Long Animation Frames (LoAF) API (Chrome 123+) to debug INP issues. LoAF provides better attribution than the legacy Long Tasks API, including script source and rendering time.
Good: < 0.1 | Needs Improvement: 0.1-0.25 | Poor: > 0.25
Layout shift sources: images without dimensions, dynamically injected content, web font FOUT, late-loading ads. Shifts within 500ms of user interaction are exempt.
- Severity: CRITICAL
- Detection:
<link.*rel="stylesheet"in<head>loading large CSS - CWV: LCP
<!-- BAD -->
<link rel="stylesheet" href="/styles/main.css" />
<!-- GOOD — inline critical CSS (extracted at build time), preload the rest -->
<style>/* critical above-fold CSS, inlined by a tool like Critters/Beasties */</style>
<link rel="preload" href="/styles/main.css" as="style" />
<link rel="stylesheet" href="/styles/main.css" />Prefer build-time critical CSS extraction (e.g., Critters, Beasties, Next.js experimental.optimizeCss) plus a normal <link rel="stylesheet">. Avoid the older media="print" onload="this.media='all'" trick: inline event handlers are blocked under a strict CSP (no 'unsafe-inline' / no script-src-attr 'unsafe-inline'), which would prevent the stylesheet from ever activating and cause a styling regression. If non-critical CSS truly must be deferred, load it via an external script that swaps media, not an inline handler.
- Severity: CRITICAL
- Detection:
<script.*src=withoutasync|defer|type="module" - CWV: LCP
<!-- BAD -->
<script src="/vendor/analytics.js"></script>
<!-- GOOD -->
<script src="/vendor/analytics.js" defer></script>- Severity: IMPORTANT
- Detection: Third-party API/CDN URLs without
<link rel="preconnect"> - CWV: LCP
<link rel="preconnect" href="https://api.example.com" />
<link rel="dns-prefetch" href="https://analytics.example.com" />- Severity: CRITICAL
- Detection: LCP image/font not preloaded
- CWV: LCP
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />- Severity: CRITICAL
- Detection:
useEffect.*fetch|useEffect.*axios|ngOnInit.*subscribe - CWV: LCP
// BAD — content appears after JS execution + API call
'use client';
function Page() {
const [data, setData] = useState(null);
useEffect(() => { fetch('/api/data').then(r => r.json()).then(setData); }, []);
return <div>{data?.title}</div>;
}
// GOOD — Server Component fetches data before HTML is sent
async function Page() {
const data = await fetch('https://api.example.com/data').then(r => r.json());
return <div>{data.title}</div>;
}- Severity: IMPORTANT
- Detection: Multiple sequential redirects (HTTP 301/302 chains)
- CWV: LCP
Each redirect adds 200-300ms. Maximum one redirect.
- Severity: IMPORTANT
- Detection: Above-fold hero image without
fetchpriority="high"orpriorityprop - CWV: LCP
// Next.js
<Image src="/hero.webp" alt="Hero" width={1200} height={600} priority />
// Angular
<img ngSrc="/hero.webp" alt="Hero" width="1200" height="600" priority>
// Plain HTML
<img src="/hero.webp" alt="Hero" width="1200" height="600" fetchpriority="high" />- Severity: IMPORTANT
- Detection:
<script.*src="https://withoutasync|defer - CWV: LCP
Defer non-essential scripts. Use facade pattern for chat widgets.
- Severity: SUGGESTION
- Detection: Server-rendered HTML larger than 14KB
- CWV: LCP
Reduce inline CSS/JS, remove whitespace, use streaming SSR with Suspense boundaries.
- Severity: IMPORTANT
- Detection: Server not returning
content-encoding: brorgzip - CWV: LCP
Enable Brotli (15-25% better than gzip) at CDN/server level.
- Severity: CRITICAL
- Detection:
"use client"at top-level layout or page component - CWV: LCP + INP
Push "use client" down to leaf components that need interactivity.
- Severity: IMPORTANT
- Detection: Server Components doing data fetching without
<Suspense> - CWV: LCP
// GOOD — stream shell immediately, fill in data progressively
async function Page() {
const user = await getUser();
return (
<div>
<Header user={user} />
<Suspense fallback={<PostsSkeleton />}>
<Posts />
</Suspense>
</div>
);
}- Severity: IMPORTANT
- Detection:
Date.now()|Math.random()|window\.innerWidthin SSR components - CWV: CLS
Use useEffect for client-only values, or suppressHydrationWarning for known differences.
- Severity: IMPORTANT
- Detection: Page awaiting all data before sending HTML
- CWV: LCP (TTFB)
Use streaming SSR with Suspense boundaries. Shell streams immediately; slow data fills in progressively.
- Severity: IMPORTANT
- Detection:
style=\{\{|onClick=\{\(\) =>inline in JSX - CWV: INP
React 19+ with React Compiler enabled (separate babel/SWC build plugin): auto-memoized. Without Compiler: extract or memoize with useMemo/useCallback. Angular: OnPush. Vue: computed().
- Severity: IMPORTANT
- Detection:
.map(rendering >100 items without virtual scrolling - CWV: INP
Use TanStack Virtual, react-window, Angular CDK Virtual Scroll, or vue-virtual-scroller.
R7: SSR of Immediately-Hidden Content
- Severity: SUGGESTION
- Detection: Server-rendering
display: nonecomponents - CWV: LCP (TTFB)
Use client-side rendering for modals, drawers, dropdowns. Angular: @defer. React: React.lazy.
- Severity: IMPORTANT
- Detection:
.map(withoutkey=prop - CWV: INP
// GOOD — stable unique key
{items.map(item => <Row key={item.id} data={item} />)}Never use array index as key if list can reorder.
- Severity: CRITICAL
- Detection: Event handlers with heavy computation (>50ms)
- CWV: INP
// GOOD — yield to browser
async function handleClick() {
setLoading(true);
await (globalThis.scheduler?.yield?.() ?? new Promise(r => setTimeout(r, 0)));
const result = expensiveComputation(data);
setResult(result);
}Move heavy work to Web Worker for best results.
Note:
scheduler.yield()is supported in Chrome 129+, Firefox 129+, but NOT Safari as of April 2026. Fallback:await (globalThis.scheduler?.yield?.() ?? new Promise(r => setTimeout(r, 0))).
- Severity: CRITICAL
- Detection:
offsetHeight|offsetWidth|getBoundingClientRect|clientHeightin loops - CWV: INP
// GOOD — batch reads then batch writes
const heights = elements.map(el => el.offsetHeight);
elements.forEach((el, i) => { el.style.height = `${heights[i] + 10}px`; });- Severity: IMPORTANT
- Detection:
setInterval|setTimeoutwithout cleanup - Impact: Memory
useEffect(() => {
const id = setInterval(() => fetchData(), 5000);
return () => clearInterval(id);
}, []);- Severity: IMPORTANT
- Detection:
addEventListenerwithout cleanup - Impact: Memory
useEffect(() => {
const controller = new AbortController();
window.addEventListener('resize', handleResize, { signal: controller.signal });
return () => controller.abort();
}, []);- Severity: SUGGESTION
- Detection: Variables holding references to removed DOM elements
- Impact: Memory
Set references to null when elements are removed.
- Severity: CRITICAL
- Detection:
XMLHttpRequestwith synchronous flag - CWV: INP
Use fetch() (always async).
- Severity: IMPORTANT
- Detection: CPU-intensive operations in component code
- CWV: INP
Move to Web Worker or break into chunks with scheduler.yield().
- Severity: IMPORTANT
- Detection:
useEffectwithout return cleanup;subscribewithout unsubscribe - Impact: Memory
React: return cleanup from useEffect. Angular: takeUntilDestroyed(). Vue: onUnmounted.
- Severity: CRITICAL
- Detection:
animation:|transition:withtop|left|width|height|margin|padding - CWV: INP
/* BAD — main thread, <60fps */
.card { transition: width 0.3s, height 0.3s; }
/* GOOD — GPU compositor, 60fps */
.card { transition: transform 0.3s, opacity 0.3s; }
.card:hover { transform: scale(1.05); }- Severity: SUGGESTION
- Detection: Long pages without
content-visibility: auto - CWV: INP
.below-fold-section {
content-visibility: auto;
contain-intrinsic-size: auto 500px;
}- Severity: SUGGESTION
- Detection:
will-change:in base CSS (not:hover|:focus) - Impact: Memory
Apply on interaction only or let browser optimize automatically.
- Severity: IMPORTANT
- Detection: CSS where >50% of rules are unused
- CWV: LCP
Use PurgeCSS, Tailwind purge, or critters. Code-split CSS per route.
- Severity: SUGGESTION
- Detection:
\* \{in CSS - CWV: INP
/* GOOD — zero-specificity reset */
:where(*, *::before, *::after) { box-sizing: border-box; }- Severity: SUGGESTION
- Detection: Complex components without
containproperty - CWV: INP
.sidebar { contain: layout style paint; }- Severity: SUGGESTION
- Detection: SPA route changes without View Transitions API
- CWV: CLS (perceived)
// Use View Transitions for smooth route changes (with feature check)
if (document.startViewTransition) {
document.startViewTransition(() => {
// update DOM / navigate
});
} else {
// fallback: update DOM directly
}Same-document transitions supported in all major browsers. Cross-document supported in Chrome/Edge 126+, Safari 18.5+. Always feature-check before calling — unsupported browsers will throw without the guard.
- Severity: CRITICAL
- Detection:
<imgwithoutwidth=andheight= - CWV: CLS
Always set width and height on images, or use aspect-ratio in CSS.
- Severity: CRITICAL
- Detection:
loading="lazy"on hero/banner images - CWV: LCP
<!-- GOOD — eager load with high priority -->
<img src="/hero.webp" alt="Hero" fetchpriority="high" />- Severity: IMPORTANT
- Detection: Images without WebP/AVIF alternatives
- CWV: LCP
<picture>
<source srcset="/hero.avif" type="image/avif" />
<source srcset="/hero.webp" type="image/webp" />
<img src="/hero.jpg" alt="Hero" width="1200" height="600" />
</picture>- Severity: IMPORTANT
- Detection:
<imgwithoutsrcset - CWV: LCP
<img src="/hero-800.jpg" alt="Hero"
srcset="/hero-400.jpg 400w, /hero-800.jpg 800w, /hero-1200.jpg 1200w"
sizes="(max-width: 600px) 400px, (max-width: 1024px) 800px, 1200px" />- Severity: IMPORTANT
- Detection:
@font-facewithoutfont-display - CWV: CLS
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap; /* or "optional" for best CLS */
}- Severity: IMPORTANT
- Detection: Custom font without
<link rel="preload"> - CWV: LCP + CLS
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin />- Severity: SUGGESTION
- Detection: Font files > 50KB WOFF2
- CWV: LCP
Use unicode-range, subset with glyphhanger, or next/font (auto-subsets Google Fonts).
- Severity: SUGGESTION
- Detection: SVGs with editor metadata
- CWV: LCP (minor)
npx svgo input.svg -o output.svg- Severity: IMPORTANT
- Detection:
from '\.\/(?:.*\/index|components)' - CWV: INP
// BAD
import { Button } from './components';
// GOOD — direct import
import { Button } from './components/Button';- Severity: IMPORTANT
- Detection:
require(in frontend code - CWV: INP
Use ESM import/export. Replace require with import.
- Severity: IMPORTANT
- Detection:
from "moment"|from "lodash"(full imports) - CWV: INP
// GOOD — tree-shakeable alternatives
import { format } from 'date-fns';
import { pick } from 'lodash-es';
// BEST — native JS
const formatted = new Intl.DateTimeFormat('en').format(date);- Severity: CRITICAL
- Detection: All route components imported statically
- CWV: INP
// Next.js: automatic with file-based routing
// React:
const Page = React.lazy(() => import('./pages/Page'));
// Angular:
{ path: 'settings', loadComponent: () => import('./pages/settings.component') }
// Vue:
const Page = defineAsyncComponent(() => import('./pages/Page.vue'));- Severity: SUGGESTION
- Detection: Library package.json without
"sideEffects"field - CWV: INP
{ "sideEffects": false }- Severity: SUGGESTION
- Detection: Same library at multiple versions
- CWV: INP
npm dedupe- Severity: IMPORTANT
- Detection:
<imgin.tsxinstead of<Image> - CWV: LCP + CLS
import Image from 'next/image';
<Image src="/hero.jpg" alt="Hero" width={1200} height={600} priority />- Severity: IMPORTANT
- Detection: Pages without
"use cache"directive in Next.js 16+ projects - CWV: LCP
// BAD — entire page is dynamic
export default async function Page() {
const data = await fetchData(); // blocks full page render
return <div>{data.title}</div>;
}
// GOOD — enable Partial Prerendering with "use cache"
// next.config.ts: { cacheComponents: true }
"use cache";
export default async function Page() {
const data = await fetchData(); // static shell renders instantly, dynamic holes stream
return <div>{data.title}</div>;
}Enable in next.config.ts with cacheComponents: true. Use "use cache" at file, component, or function level. Static shell loads instantly; dynamic content streams via Suspense boundaries.
- Severity: IMPORTANT
- Detection:
"use client"on components without hooks or browser APIs - CWV: INP
Remove "use client" from components that only render static content.
- Severity: CRITICAL
- Detection:
useEffect+fetchin Next.js App Router pages - CWV: LCP
Fetch data in Server Components directly (async function body).
- Severity: IMPORTANT
- Detection:
fonts.googleapis|fonts.gstaticin CSS/HTML - CWV: CLS + LCP
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'] });- Severity: IMPORTANT
- Detection: Async server functions without
"use cache"in Next.js 16+ withcacheComponents: true - CWV: LCP
// BAD — data fetched on every request
async function getProducts() {
return await db.products.findMany();
}
// GOOD — cached with revalidation
"use cache";
import { cacheLife } from 'next/cache';
async function getProducts() {
cacheLife('hours');
return await db.products.findMany();
}"use cache" replaces the old unstable_cache and fetch cache options. Use cacheLife() and cacheTag() for fine-grained control.
- Severity: IMPORTANT
- Detection: Components without
ChangeDetectionStrategy.OnPush(Angular <19) or without signals (Angular 19+) - CWV: INP
// Angular <19: Use OnPush
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
...
})
// Angular 19+: Prefer zoneless with signals
// app.config.ts: provideZonelessChangeDetection()
@Component({ ... })
export class ProductCard {
product = input.required<Product>(); // signal input
price = computed(() => this.product().price * 1.19); // derived signal
}Angular 19+: prefer zoneless change detection with signals. OnPush is unnecessary when using signal-based reactivity. Angular 20+ has stable zoneless support.
- Severity: IMPORTANT
- Detection:
<imgwithoutngSrcin.component.html - CWV: LCP + CLS
<img ngSrc="/hero.jpg" alt="Hero" width="1200" height="600" priority />- Severity: SUGGESTION
- Detection: Heavy below-fold components loaded eagerly (Angular 17+)
- CWV: INP
@defer (on viewport) {
<app-heavy-chart [data]="chartData" />
} @placeholder {
<div class="chart-skeleton"></div>
}- Severity: SUGGESTION
- Detection: Class properties without signals in Angular 19+
- CWV: INP
Use signal() for reactive state, computed() for derived values. Signal APIs (signal(), computed(), effect()) are stable since Angular 20.
- Severity: IMPORTANT
- Detection: SSR app without
withIncrementalHydration()in Angular 19+ - CWV: LCP, INP
// BAD — full hydration blocks interactivity
provideClientHydration()
// GOOD — incremental hydration with triggers
provideClientHydration(withIncrementalHydration())Use @defer triggers (on viewport, on interaction) to hydrate components on demand. Reduces TTI by deferring non-critical component hydration.
- Severity: SUGGESTION
- Detection:
zone.jsin polyfills array, noprovideZonelessChangeDetection()in Angular 20+ - CWV: INP
// app.config.ts
export const appConfig = {
providers: [
provideZonelessChangeDetection(), // removes ~15-30KB from bundle
// ...
]
};Zoneless change detection with signals reduces bundle size and improves runtime performance. Stable since Angular 20.
- Severity: SUGGESTION
- Detection: Manual
useMemo|useCallbackin React 19+ project - CWV: INP
Enable React Compiler (v19+) for auto-memoization. Remove manual wrappers.
- Severity: IMPORTANT
- Detection: State updates causing expensive re-renders without
useTransition - CWV: INP
const [isPending, startTransition] = useTransition();
function handleFilter(value) {
startTransition(() => setFilter(value));
}- Severity: IMPORTANT
- Detection: Expensive rendering from rapidly-changing input
- CWV: INP
const deferredQuery = useDeferredValue(query);
const results = expensiveFilter(items, deferredQuery);- Severity: IMPORTANT
- Detection: Route components imported statically
- CWV: INP
const Settings = React.lazy(() => import('./pages/Settings'));- Severity: IMPORTANT
- Detection:
reactive(on large arrays or deep objects - CWV: INP
Use shallowRef() or shallowReactive() for large data.
- Severity: SUGGESTION
- Detection: Large lists without
v-memo - CWV: INP
<div v-for="item in items" :key="item.id" v-memo="[item.id, item.updatedAt]">
<ExpensiveItem :data="item" />
</div>- Severity: IMPORTANT
- Detection: Heavy components imported statically
- CWV: INP
const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'));- Severity: SUGGESTION
- Detection: Performance-critical components using virtual DOM in Vue 3.6+
- CWV: INP
Vue 3.6+ Vapor Mode compiles templates to direct DOM operations, bypassing the virtual DOM. Use for performance-critical subtrees. Can be mixed with standard components.
| Hint | Purpose | When to Use |
|---|---|---|
preconnect |
DNS + TCP + TLS early | Critical third-party origins (API, CDN, fonts) |
preload |
Fetch immediately, high priority | LCP image, critical font |
prefetch |
Low priority for future navigation | Next-page assets |
dns-prefetch |
DNS resolution only | Non-critical third-party origins |
modulepreload |
Preload + parse ES module | Critical JS modules |
<script type="speculationrules"> |
Prefetch/prerender next navigation | Likely next pages (Chrome 121+, progressive enhancement) |
| Aspect | Recommendation |
|---|---|
| Format | WebP (25-34% smaller), AVIF (50% smaller) |
| LCP image | fetchpriority="high" or framework priority prop |
| Below-fold | loading="lazy" |
| Dimensions | Always set width + height |
| Responsive | srcset + sizes or framework Image component |
| Compression | Quality 75-85 for photos |
| Strategy | Best For | CLS Impact |
|---|---|---|
font-display: swap |
Body text | Slight FOUT, minimal CLS |
font-display: optional |
All fonts (best CLS) | No FOUT, no CLS |
next/font |
Next.js projects | Zero CLS |
| Variable fonts | Multiple weights | Single file for all weights |
Rules: preload 1-2 critical fonts only, use WOFF2, subset to needed characters, self-host when possible.
- LCP image has
fetchpriority="high"orpriorityprop - LCP image preloaded if not in HTML source
- No
loading="lazy"on above-fold images - Critical CSS inlined or extracted
- No render-blocking scripts (use
deferorasync) - Preconnect to critical third-party origins
- Main content server-rendered (not client-side fetched)
- Images in modern format (WebP/AVIF) with responsive
srcset - Compression enabled (Brotli preferred)
- Fonts preloaded with
font-display: swaporoptional
- Event handlers complete in < 50ms
- Long tasks broken into smaller chunks
- Route-based code splitting implemented
- Heavy computation moved to Web Workers
- Lists with > 100 items virtualized
- No barrel file imports (direct component imports)
- ESM imports used (not CommonJS
require) -
"use client"only on components that need interactivity - Layout-triggering CSS properties not animated
- Effect cleanup implemented (no leaking listeners/timers)
- All images have
widthandheightattributes - Fonts use
font-display: swaporoptional - No content injected above existing content dynamically
- Ads/embeds have reserved space
- No hydration mismatches
-
content-visibility: autohascontain-intrinsic-size