9.1 KiB
Optimization Playbook
A step-by-step playbook for common performance problems. Pick the symptom, follow the steps.
Symptom: LCP failing (>2.5s)
The largest content element on the page takes too long to paint.
Step 1: Identify the LCP element
In Chrome DevTools Performance panel:
- Record a page load
- Find the LCP marker
- Note which element is the LCP (usually a hero image or large heading)
Step 2: Optimize that specific element
If LCP element is an image:
- Use modern format (WebP, AVIF)
- Serve appropriately sized version (don't ship a 4000px image to a 800px container)
- Preload it:
<link rel="preload" as="image" href="hero.webp" fetchpriority="high"> - Avoid lazy-loading the LCP image (use eager loading)
- Use
fetchpriority="high"on the image element
If LCP element is text/heading:
- Ensure web fonts don't block rendering (
font-display: swap) - Inline critical CSS for the heading section
- Preconnect to font origin:
<link rel="preconnect" href="https://fonts.example.com">
If LCP is loading slowly because the page is slow overall:
- See "Symptom: TTFB slow" below
- Reduce render-blocking resources
- Reduce total payload before LCP element
Step 3: Measure
Re-test in Lighthouse and DevTools. Verify LCP under 2.5s on a slow connection (Slow 4G simulation in DevTools).
Symptom: INP failing (>200ms)
Interactions feel sluggish. The page takes too long to respond to user input.
Step 1: Find the slow interactions
In Chrome DevTools Performance panel:
- Record while clicking, typing, or interacting
- Look for long tasks (orange bars >50ms) following input events
- Identify the function calls that take the longest
Step 2: Reduce the work
For event handlers:
- Move heavy work out of the synchronous path
- Use
setTimeoutorrequestIdleCallbackto yield to the main thread - Debounce or throttle frequent events (scroll, mouse move, resize)
For component re-renders (React, Vue, etc.):
- Memoize expensive computations
- Avoid creating new objects/arrays/functions in render that get passed as props
- Use virtualization for large lists (react-window, virtua, or framework equivalent)
- Profile to find which components re-render and why
For startup hydration (SSR/SSG frameworks):
- Defer hydration of non-critical components
- Use island architecture or partial hydration where supported
- Code-split aggressively below-the-fold
Step 3: Verify
INP should be under 200ms at the 75th percentile of real user data.
Symptom: CLS failing (>0.1)
Content jumps around as the page loads.
Step 1: Identify the shifts
In Chrome DevTools:
- Open Performance Insights or Performance panel
- Look for "Layout Shifts" markers
- Click each to see what shifted
Step 2: Reserve space for dynamic content
Common offenders and fixes:
- Images without dimensions. Add
widthandheightattributes (oraspect-ratioCSS). - Late-loading ads or embeds. Reserve a fixed-size container.
- Web fonts swapping. Use
font-display: optionalor size-adjust descriptors. - Skeleton loaders that don't match final size. Match dimensions exactly.
- Animations triggering layout. Animate only
transformandopacity. - Insertion of content above existing content. Reserve space or insert below.
Step 3: Verify
Run Lighthouse. Real-user CLS at 75th percentile should be under 0.1.
Symptom: TTFB slow (>800ms)
Server takes too long to respond.
Step 1: Check the server response
Use WebPageTest or curl -w "@curl-format.txt" -s -o /dev/null https://example.com to measure just TTFB.
Step 2: Identify the bottleneck
Common causes:
- No caching at the edge. Add a CDN. Set Cache-Control headers correctly.
- Slow database queries. Run EXPLAIN on slow queries. Add indexes. Eliminate N+1 patterns.
- Slow third-party API calls in the render path. Defer to client or precompute.
- Heavy server-side rendering. Profile the SSR work. Memoize what's repeated.
- Cold starts (serverless). Provisioned concurrency or warming pings.
Step 3: Verify
TTFB under 600ms at the 75th percentile is the target. Under 200ms is excellent.
Symptom: Bundle size too large
JavaScript payload bloating the page.
Step 1: Audit the bundle
Run a bundle analyzer (Webpack Bundle Analyzer, source-map-explorer, etc.). Sort by size.
Step 2: Identify the offenders
Common bloat sources:
- Unused dependencies. Tree-shaking missing or broken.
- Whole-library imports.
import { debounce } from 'lodash'pulls all lodash. Useimport debounce from 'lodash/debounce'. - Polyfills shipped to modern browsers. Use differential serving.
- Source maps in production bundle. Should be separate files, not inline.
- Moment.js, Lodash, jQuery. Often replaceable with smaller alternatives or native APIs.
- Multiple versions of the same library. Check
npm ls [library]. Dedupe.
Step 3: Reduce
- Replace heavy libraries with lighter alternatives (date-fns or dayjs instead of moment, native fetch instead of axios)
- Code-split routes
- Code-split heavy components (modals, charts, editors)
- Use dynamic imports for below-the-fold features
Step 4: Set a budget
In your build config or CI, fail builds that exceed the budget.
Symptom: Image-heavy page loads slowly
Step 1: Audit images
- Count images on the page
- Note current sizes and formats
- Note which are above-the-fold (eagerly loaded) vs below (should be lazy)
Step 2: Optimize each
Format:
- Photos: WebP or AVIF (with JPEG fallback)
- Graphics: SVG or PNG (with WebP fallback)
- Animations: video (MP4/WebM) instead of GIF for size
Sizing:
- Generate multiple sizes via
srcset - Use
sizesattribute to tell the browser the rendered size
<img
src="hero-800.webp"
srcset="hero-400.webp 400w, hero-800.webp 800w, hero-1600.webp 1600w"
sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1600px"
width="1600"
height="900"
alt="Description"
loading="eager"
fetchpriority="high"
/>
For below-the-fold:
loading="lazy"- Without
fetchpriority
Compression:
- Use a build-time image pipeline (sharp, imagemin) to compress automatically
- Quality 75-85 is usually invisible to users while halving file size
Symptom: Web fonts blocking text
Step 1: Audit font loading
In DevTools Network tab, filter by font. Note:
- File size
- When they load relative to first paint
- Whether text is invisible or fallback during load
Step 2: Optimize
- Use
font-display: swapto show fallback text immediately - Subset fonts to only the characters needed (Latin only is much smaller than full Unicode)
- Self-host critical fonts instead of CDN where possible
- Preload critical fonts:
<link rel="preload" href="/fonts/regular.woff2" as="font" type="font/woff2" crossorigin> - Use
size-adjust,ascent-override,descent-overridein@font-faceto match metrics with fallback fonts (reduces CLS)
Symptom: Third-party scripts dragging performance
Step 1: Inventory third-parties
In DevTools Network tab, list all third-party domains. For each:
- Size
- Blocking or non-blocking?
- Necessary?
Step 2: Decide on each
For every third-party script, ask: does the business value justify the performance cost?
Common offenders:
- Heavy analytics (audit if all pixels are necessary; consider server-side tagging)
- Customer support chat widgets (load on user intent, not on page load)
- A/B testing tools (defer non-critical experiments)
- Multiple ad networks (consolidate)
- Tag managers loading hundreds of tags
Fixes:
- Remove unused tags
- Defer non-critical scripts (
asyncordefer) - Self-host where licensing allows (avoids extra DNS lookup)
- Load chat widgets on user interaction, not on page load
- Consolidate analytics where possible
Step 3: Set a third-party budget
Number of third-party domains, total third-party JS payload. Block-add for new third-parties without explicit performance review.
Performance budget template
Set hard limits in your CI to prevent regression:
budget:
- resourceSizes:
- resourceType: total
budget: 500 # kilobytes
- resourceType: script
budget: 200
- resourceType: image
budget: 200
- resourceType: stylesheet
budget: 50
- resourceType: font
budget: 100
- resourceType: third-party
budget: 100
- timings:
- metric: largest-contentful-paint
budget: 2500 # milliseconds
- metric: cumulative-layout-shift
budget: 0.1
- metric: total-blocking-time
budget: 200
Tools like Lighthouse CI, Calibre, or SpeedCurve can enforce these budgets in CI.
When to call it done
A page is "performance optimized" when:
- Real-user Core Web Vitals at 75th percentile pass thresholds
- Lighthouse performance score >90 on representative pages
- Performance budgets enforced in CI
- Periodic re-audit scheduled (quarterly minimum)
Performance is not a one-time project. New code adds weight. New features add complexity. Plan for ongoing maintenance.