@ctxr/skill-frontend-excellence 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,416 @@
1
+ # Performance Deep Dive
2
+
3
+ Lighthouse measures the symptoms; this document is about the underlying causes and the levers you actually pull. Framework-agnostic.
4
+
5
+ ## Mental Model: The Loading Pipeline
6
+
7
+ Every page goes through these phases:
8
+
9
+ 1. **Network**: DNS, TCP, TLS, HTTP (request -> first byte). Controlled by edge config and resource hints.
10
+ 2. **HTML parse**: browser reads HTML, discovers sub-resources. Controlled by HTML structure and `<link>` order.
11
+ 3. **CSS parse + render-tree build**: blocked by stylesheets in `<head>`. Controlled by critical CSS.
12
+ 4. **JS download + parse + execute**: blocked by sync scripts. Controlled by `defer`/`async`/module strategy.
13
+ 5. **First paint**: when something visible appears.
14
+ 6. **LCP paint**: when the largest-in-viewport element finishes painting.
15
+ 7. **Hydration / interactivity**: when JS attaches event handlers and the page becomes responsive (TBT/INP).
16
+ 8. **Lazy phase**: below-the-fold images, non-critical scripts, deferred islands.
17
+
18
+ Every optimization moves work between phases or eliminates it. The cheapest optimization is the work you don't do.
19
+
20
+ ## Core Web Vitals: What Each Means and How to Move It
21
+
22
+ ### Largest Contentful Paint (LCP)
23
+
24
+ LCP is the render time of the largest text block, image, video poster, or background image in the initial viewport. Target: < 2.5s mobile, < 2.0s desktop.
25
+
26
+ **The four phases of LCP** (from Google's framework):
27
+
28
+ | Phase | Typical share | Lever |
29
+ |-------|--------------|-------|
30
+ | TTFB (server response) | 25-40% | Edge caching, server location, route compute |
31
+ | Resource load delay | 0-30% | Preconnect, preload, critical-CSS inlining, font preload |
32
+ | Resource load duration | 30-60% | Compression, format (AVIF/WebP), responsive sizing |
33
+ | Render delay | 0-20% | Render-blocking JS/CSS, font swap, hydration cost |
34
+
35
+ **Concrete LCP fixes, ordered by impact:**
36
+
37
+ 1. **Identify the LCP element.** Open DevTools -> Performance -> capture a load -> find the LCP marker. The element will be named.
38
+ 2. **If it's an image:**
39
+ - Serve as AVIF (primary) and WebP (fallback). Compression: AVIF q=50-65, WebP q=75-82.
40
+ - Use `<picture>` with type-keyed sources.
41
+ - Set `width` and `height` (or `aspect-ratio`) to prevent CLS.
42
+ - Add `fetchpriority="high"`.
43
+ - Never `loading="lazy"` on the LCP image.
44
+ - Use `srcset` with at least 3 widths, plus `sizes` matching the rendered box.
45
+ - Preload only when the image is rendered by client JS (CSR/hydration). Server-rendered `<img>` does not need preload.
46
+ 3. **If it's text:**
47
+ - Ensure the font that renders it is preloaded: `<link rel="preload" as="font" type="font/woff2" crossorigin>`.
48
+ - Set `font-display: swap` so fallback shows immediately.
49
+ - Use `size-adjust`, `ascent-override`, `descent-override`, `line-gap-override` on `@font-face` to match metric to fallback and prevent CLS on swap.
50
+ - Avoid web fonts entirely for the largest hero text if possible (use a system stack).
51
+ 4. **If it's a video poster:** treat as image; add `poster` attribute, declare dimensions.
52
+ 5. **Reduce TTFB:**
53
+ - Cache HTML at the edge with `s-maxage` + `stale-while-revalidate`.
54
+ - Move compute close to users (edge functions, regional deploy).
55
+ - Avoid blocking origin compute on slow data fetches; stream HTML if the framework supports it.
56
+
57
+ ### Interaction to Next Paint (INP)
58
+
59
+ INP measures the slowest interaction during the page lifetime. Target: < 200ms (good), < 500ms (needs improvement).
60
+
61
+ INP is dominated by:
62
+
63
+ - Long tasks during the interaction (event handler running > 50ms)
64
+ - Long tasks blocking the next paint after the handler returns
65
+ - Synchronous layout reads in handlers
66
+ - Large React/Vue/Svelte re-render triggered by the interaction
67
+
68
+ **Levers:**
69
+
70
+ 1. Break long tasks. Use `scheduler.yield()` (or `setTimeout(fn, 0)` / `requestIdleCallback` fallback) to chunk work.
71
+ 2. Move expensive work off the main thread (Web Workers, OffscreenCanvas).
72
+ 3. Avoid sync layout thrash: batch reads, then writes. Read all `getBoundingClientRect()` once, then write.
73
+ 4. Memoize expensive renders. In React, use `useMemo`, `useCallback`, `React.memo`. In Vue, `computed`. In Svelte, `$derived` (Svelte 5).
74
+ 5. Use CSS for state when possible (`:hover`, `:active`, `:focus-visible`, `:has()`, `:checked`, popover API) instead of JS state updates.
75
+ 6. For large lists, virtualize with libraries like TanStack Virtual or framework-native.
76
+ 7. For input fields, debounce expensive computations (search, validation) with 150-300ms debounce.
77
+
78
+ ### Cumulative Layout Shift (CLS)
79
+
80
+ CLS sums the layout-shift scores during the page session. Target: < 0.1 (mobile), < 0.05 (desktop).
81
+
82
+ **Common shift sources:**
83
+
84
+ - Images without dimensions
85
+ - Iframes/embeds without dimensions
86
+ - Late-loading fonts causing text re-flow (FOUT)
87
+ - Late-injected banners (cookie consent, promo bars)
88
+ - Ads injected above existing content
89
+ - Animations that change layout (width/height/top/left/margin)
90
+ - Scrollbar appearance/disappearance on overflow change
91
+
92
+ **Fixes:**
93
+
94
+ 1. Declare `width` and `height` on every `<img>`, `<iframe>`, `<video>`. Use `aspect-ratio` for fluid layouts.
95
+ 2. Reserve space for late content with `min-height` or skeletons.
96
+ 3. Cookie/consent banners go below the fold or as overlay (fixed position, not in document flow).
97
+ 4. For font swap, use `size-adjust` and `*-override` properties on `@font-face` to size-match the fallback so swap is invisible.
98
+ 5. Animate only `transform` and `opacity`. Never animate layout-affecting properties.
99
+ 6. For dynamic content (search results), use `min-height` on the container so the page doesn't grow as results arrive.
100
+
101
+ ### Time to First Byte (TTFB)
102
+
103
+ TTFB is the time from request to first byte received. Target: < 800ms mobile, < 600ms desktop.
104
+
105
+ **Levers:**
106
+
107
+ 1. Edge cache HTML where possible (SSG, ISR, edge functions).
108
+ 2. Use a CDN with global PoPs (Cloudflare, Fastly, CloudFront, Vercel Edge).
109
+ 3. For dynamic pages, stream the response so the first byte arrives before all data is fetched.
110
+ 4. Move compute to the edge (Cloudflare Workers, Vercel Edge, Deno Deploy).
111
+ 5. Profile the server route. Look for serial DB calls, slow third-party APIs, cold starts.
112
+
113
+ ## JavaScript Strategy
114
+
115
+ ### Render strategies
116
+
117
+ | Strategy | When to use | Pros | Cons |
118
+ |----------|------------|------|------|
119
+ | Static (SSG) | Content that rarely changes per request | Cheapest, fastest, cacheable forever at edge | Stale until rebuild |
120
+ | Incremental Static (ISR) | Mostly-static with periodic updates | Edge speed + freshness | Build complexity |
121
+ | Server-rendered (SSR) | Per-request or per-user content | Always fresh | Compute cost, slower TTFB |
122
+ | Streaming SSR | Surfaces with slow data dependencies | First byte fast, content streams in | Framework support varies |
123
+ | Client-side (CSR / SPA) | Highly interactive, indexability not required | Rich interactions | Worst TTFB, worst SEO |
124
+ | Islands (partial hydration) | Mostly-static with interactive widgets | Best of both worlds | Newer pattern |
125
+ | Resumability (Qwik) | Maximum INP | Near-zero hydration cost | Newer ecosystem |
126
+
127
+ Decide per surface: when content is the same for everyone and changes infrequently, prefer SSG / ISR with islands. When content is per-request or per-user, prefer SSR or streaming SSR. Reach for full CSR / SPA only when the surface is genuinely application-like and indexability is not a concern.
128
+
129
+ ### Hydration cost
130
+
131
+ The cost is roughly: bundle parse + module init + component constructor + diff against existing DOM. For React 18+:
132
+
133
+ - Server components don't ship JS at all. Use them for static structure.
134
+ - `'use client'` boundaries should be small leaves, not big trees.
135
+ - Lazy hydrate below-the-fold islands with `<Suspense>` + dynamic import or framework helpers.
136
+
137
+ For other frameworks:
138
+
139
+ - **Astro**: islands by default. Mark client components with `client:idle`, `client:visible`, `client:load` per criticality.
140
+ - **Qwik**: resumability instead of hydration. Components don't re-execute on the client unless the user interacts.
141
+ - **SvelteKit**: SSR + selective client. Use `<svelte:head>`, route-level `+page.server.ts`.
142
+ - **Vue/Nuxt**: similar tradeoffs to React. `<NuxtIsland>` for partial hydration.
143
+ - **Solid Start**: SSR + reactive primitives, low hydration cost.
144
+
145
+ ### Bundle splitting
146
+
147
+ Default splits:
148
+
149
+ - Per route (every framework supports this).
150
+ - Per dynamic import (lazy-loaded modal, chart, editor).
151
+ - Per third party (vendor chunk).
152
+
153
+ Anti-patterns:
154
+
155
+ - One giant `vendor.js`. Split heavy libs (chart, editor, video player) so they only load on pages that use them.
156
+ - Importing default exports of barrel files. They prevent treeshaking. Import named exports directly: `import { foo } from 'lib/foo'`, not `import { foo } from 'lib'`.
157
+ - Dev-only code shipped to production. Wrap with `process.env.NODE_ENV !== 'production'` (or framework equivalent) so the bundler tree-shakes it.
158
+
159
+ ### Treeshaking
160
+
161
+ For treeshaking to work:
162
+
163
+ - Use ES modules end-to-end (`"type": "module"` or `.mjs`).
164
+ - Mark library `package.json` with `"sideEffects": false` (or list specific files with side effects).
165
+ - Avoid importing entire libraries: `import _ from 'lodash'` blocks treeshaking; use `import debounce from 'lodash/debounce'` or replace with native.
166
+
167
+ ## Image Strategy
168
+
169
+ ### Format priority
170
+
171
+ 1. **AVIF** for photographic content. ~50% smaller than JPEG at equal quality.
172
+ 2. **WebP** as fallback for older browsers (Safari < 16). ~30% smaller than JPEG.
173
+ 3. **JPEG** as final fallback. Use mozjpeg with quality 75-82 and progressive=true.
174
+ 4. **PNG** only for graphics with transparency that AVIF/WebP can't handle (rare).
175
+ 5. **SVG** for icons, logos, illustrations. Inline when small (< 1 KB), reference when large.
176
+
177
+ ### Responsive images
178
+
179
+ Every non-trivial image needs `srcset` and `sizes`:
180
+
181
+ ```html
182
+ <picture>
183
+ <source
184
+ type="image/avif"
185
+ srcset="/img/hero-480.avif 480w, /img/hero-960.avif 960w, /img/hero-1440.avif 1440w"
186
+ sizes="(max-width: 640px) 100vw, (max-width: 1024px) 80vw, 1280px"
187
+ />
188
+ <source
189
+ type="image/webp"
190
+ srcset="/img/hero-480.webp 480w, /img/hero-960.webp 960w, /img/hero-1440.webp 1440w"
191
+ sizes="(max-width: 640px) 100vw, (max-width: 1024px) 80vw, 1280px"
192
+ />
193
+ <img
194
+ src="/img/hero-960.jpg"
195
+ width="1440"
196
+ height="900"
197
+ alt="..."
198
+ fetchpriority="high"
199
+ decoding="async"
200
+ />
201
+ </picture>
202
+ ```
203
+
204
+ ### Lazy loading
205
+
206
+ - `loading="lazy"` on every `<img>` and `<iframe>` below the fold.
207
+ - Never on the LCP image.
208
+ - For video, use `preload="metadata"` and lazy-load the actual video on user interaction.
209
+
210
+ ### Aspect-ratio reservation
211
+
212
+ For fluid layouts where width is dynamic but aspect ratio is known:
213
+
214
+ ```css
215
+ .hero-image {
216
+ aspect-ratio: 16 / 9;
217
+ width: 100%;
218
+ height: auto;
219
+ }
220
+ ```
221
+
222
+ This reserves space and prevents CLS without hardcoding pixel dimensions.
223
+
224
+ ## Font Strategy
225
+
226
+ Web fonts are one of the largest performance regressions any surface can absorb. Treat them as a budget.
227
+
228
+ ### Budget
229
+
230
+ - Maximum 2 font families per page (1 display + 1 body, or 1 family with multiple weights).
231
+ - Maximum 4 weights total.
232
+ - Subset to the languages you actually serve (Latin, Latin-extended, Cyrillic, etc.).
233
+ - Use variable fonts when available; one variable file replaces 3-5 static weights.
234
+ - Self-host. Google Fonts CDN is OK but adds an extra DNS lookup; self-hosted is faster.
235
+
236
+ ### Loading strategy
237
+
238
+ ```html
239
+ <!-- Preload the critical body font, woff2 only -->
240
+ <link rel="preload" as="font" type="font/woff2" href="/fonts/body-regular.woff2" crossorigin />
241
+ ```
242
+
243
+ ```css
244
+ @font-face {
245
+ font-family: 'BrandBody';
246
+ src: url('/fonts/body-regular.woff2') format('woff2');
247
+ font-weight: 400;
248
+ font-style: normal;
249
+ font-display: swap;
250
+ /* CLS-prevention: match metrics to a fallback */
251
+ size-adjust: 100%;
252
+ ascent-override: 90%;
253
+ descent-override: 22%;
254
+ line-gap-override: 0%;
255
+ }
256
+ ```
257
+
258
+ - `font-display: swap` for body fonts: shows fallback, then swaps. Slight FOUT, no FOIT.
259
+ - `font-display: optional` for display fonts when network is poor: shows fallback only if font isn't ready in 100ms. Best for LCP.
260
+ - `font-display: block` is forbidden in production (causes invisible text / FOIT).
261
+
262
+ ## CSS Strategy
263
+
264
+ ### Critical CSS
265
+
266
+ The first 14 KB of HTML fits in one TCP round trip. Inline the CSS needed to render the first viewport here. Defer the rest.
267
+
268
+ Two common approaches:
269
+
270
+ 1. **Build-time critical CSS extraction** (Critters, Penthouse). Works for static pages.
271
+ 2. **Component-scoped CSS** that ships only what's used (CSS Modules, vanilla-extract, Tailwind in JIT mode). Works for any framework.
272
+
273
+ ### Defer non-critical CSS
274
+
275
+ ```html
276
+ <link
277
+ rel="preload"
278
+ href="/css/below-fold.css"
279
+ as="style"
280
+ onload="this.rel='stylesheet'"
281
+ />
282
+ <noscript><link rel="stylesheet" href="/css/below-fold.css" /></noscript>
283
+ ```
284
+
285
+ Or:
286
+
287
+ ```html
288
+ <link rel="stylesheet" href="/css/below-fold.css" media="print" onload="this.media='all'" />
289
+ ```
290
+
291
+ ### Avoid CSS bloat
292
+
293
+ - Run a production purge step (PurgeCSS, Tailwind JIT, UnoCSS).
294
+ - Avoid loading entire UI library themes (Material UI base CSS, Bootstrap full theme). Import only what's used.
295
+ - Use logical properties (`margin-inline`, `padding-block`) so RTL works without duplicating rules.
296
+
297
+ ## Network Strategy
298
+
299
+ ### Resource hints
300
+
301
+ Use sparingly. Every hint costs a connection slot.
302
+
303
+ - `dns-prefetch`: lowest cost, just resolves DNS. Use for low-priority third parties.
304
+ - `preconnect`: DNS + TCP + TLS. Use for high-priority third parties (your CDN, your API origin).
305
+ - `preload`: forces an early fetch. Use only for critical resources (LCP image, critical font).
306
+ - `prefetch`: low-priority hint for next-page resources. Use for likely next navigation.
307
+ - `modulepreload`: for ES module dependencies.
308
+
309
+ Anti-patterns:
310
+
311
+ - Preloading every font weight. Pick one or two.
312
+ - Preconnecting to 10+ origins. Pick three.
313
+ - Preloading images that are already discovered in the HTML. Wasteful.
314
+
315
+ ### HTTP/2 and HTTP/3
316
+
317
+ - HTTP/2 multiplexing means many small requests are fine. Don't over-bundle.
318
+ - HTTP/3 (QUIC) reduces handshake latency. Most modern CDNs support it; verify with `curl --http3`.
319
+
320
+ ### Compression
321
+
322
+ - Brotli for static assets. ~20% better than gzip on text.
323
+ - gzip as fallback.
324
+ - No compression for already-compressed assets (images, video, woff2).
325
+
326
+ ### Caching
327
+
328
+ | Asset | Cache-Control |
329
+ |-------|--------------|
330
+ | Versioned static (`/static/abc123.js`) | `public, max-age=31536000, immutable` |
331
+ | Versioned image | `public, max-age=31536000, immutable` |
332
+ | HTML | `public, max-age=0, s-maxage=300, stale-while-revalidate=86400` |
333
+ | API JSON (read-mostly) | `public, max-age=30, s-maxage=60, stale-while-revalidate=300` |
334
+ | API JSON (private / per-user) | `private, max-age=0, must-revalidate` |
335
+ | Service worker | `no-cache` |
336
+
337
+ ## Third-Party Strategy
338
+
339
+ Third-party scripts are the silent assassin of Lighthouse scores. Treat them as a budget item.
340
+
341
+ ### Audit
342
+
343
+ For each third party, record:
344
+
345
+ - What it does (analytics, ads, chat, embed, A/B test, error tracking)
346
+ - Bytes (compressed)
347
+ - Main-thread time (Lighthouse third-party-summary)
348
+ - Whether it blocks rendering
349
+ - Whether removal is acceptable
350
+
351
+ ### Mitigations
352
+
353
+ 1. **Remove first.** Most pages have at least one third party that contributes nothing measurable.
354
+ 2. **Lazy-load on idle.** Wrap with `requestIdleCallback` or load after `load` event.
355
+ 3. **Lazy-load on interaction.** Chat widgets load on hover/click of the trigger, not on page load.
356
+ 4. **Move to a worker.** Use `partytown` to run analytics/ads on a Web Worker so they don't compete for the main thread.
357
+ 5. **Self-host.** Google Tag Manager and others can be proxied through your own origin to remove the extra connection.
358
+ 6. **Use a server-side equivalent.** GA4 supports server-side via Measurement Protocol; ad attribution can be server-side; A/B tests can be edge-side.
359
+
360
+ ## Performance Budgets in CI
361
+
362
+ Add a budget guard to CI. Two common tools:
363
+
364
+ 1. **Lighthouse CI assertions** (covered in lighthouse.md).
365
+ 2. **`bundlesize`** or framework-specific budgets:
366
+
367
+ ```json
368
+ "budgets": [
369
+ { "type": "initial", "maximumWarning": "120kb", "maximumError": "160kb" },
370
+ { "type": "anyComponentStyle", "maximumWarning": "20kb" }
371
+ ]
372
+ ```
373
+
374
+ 3. **`size-limit`** for granular file size checks:
375
+
376
+ ```json
377
+ {
378
+ "size-limit": [
379
+ { "path": "dist/main.js", "limit": "90 KB" },
380
+ { "path": "dist/main.css", "limit": "25 KB" }
381
+ ]
382
+ }
383
+ ```
384
+
385
+ CI fails the PR if budgets are exceeded.
386
+
387
+ ## Real-User Monitoring
388
+
389
+ Lab numbers (Lighthouse) are necessary but not sufficient. Instrument production:
390
+
391
+ ```html
392
+ <script type="module">
393
+ import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'https://unpkg.com/web-vitals@4?module';
394
+ const send = (m) => navigator.sendBeacon('/rum', JSON.stringify(m));
395
+ onLCP(send); onINP(send); onCLS(send); onFCP(send); onTTFB(send);
396
+ </script>
397
+ ```
398
+
399
+ Track p75 (the metric Google uses for ranking signals) over a 28-day window. Alert when p75 crosses the threshold.
400
+
401
+ ## Common Performance Mistakes
402
+
403
+ - **Hero carousel.** Each slide is a hero candidate; LCP is unpredictable. Replace with one strong hero or use IntersectionObserver to load slides only when active.
404
+ - **Animated gradient backgrounds.** Continuous repaint. Use a static gradient or a CSS-only animation that runs once.
405
+ - **Background videos as decoration.** Heavy bytes, heavy CPU, distracting. Replace with a still image or a short looping low-bitrate WebM.
406
+ - **Massive sprite sheets.** Decoding cost. Use individual SVGs or a font-icon system.
407
+ - **Auto-playing audio.** Browser blocks it; the script keeps trying.
408
+ - **Overusing `requestAnimationFrame`.** Each rAF runs every frame. Cancel when off-screen.
409
+ - **Long-running setInterval.** Eats battery; replace with on-demand updates.
410
+ - **Synchronous storage reads in render path.** `localStorage.getItem` is sync and blocking; cache to a variable.
411
+
412
+ ## See Also
413
+
414
+ - [lighthouse.md](lighthouse.md) for score-driven audit fixes
415
+ - [responsive.md](responsive.md) for layout integrity
416
+ - [motion.md](motion.md) for animation costs