All topics
General · Learning hub

Performance Optimization notes for developers

Master Performance Optimization with a curated set of 2 developer notes — core concepts, patterns, and interview prep. Maintained by the DevRecall team.

Save this stack to your DevRecallMore General notes
Performance Optimization

Core Web Vitals & Loading Performance

Core Web Vitals & Loading Performance LCP, INP, CLS — what Google ranks on, what each one measures, and the concrete levers to move them. The Three Vitals Metri

Core Web Vitals & Loading Performance

LCP, INP, CLS — what Google ranks on, what each one measures, and the concrete levers to move them.

The Three Vitals

Metric  Measures                        Good    Needs    Poor
──────  ──────────────────────────────  ──────  ───────  ──────
LCP     Largest contentful paint        ≤ 2.5s  ≤ 4.0s   > 4.0s
INP     Interaction to next paint       ≤ 200ms ≤ 500ms  > 500ms
CLS     Cumulative layout shift         ≤ 0.1   ≤ 0.25   > 0.25

LCP = when the biggest above-the-fold image/text is painted.
INP = worst slow interaction the user experienced on the page.
CLS = total layout shift score (unexpected element movement).

These are p75 across all page loads — fix the long tail, not the median.

Improving LCP

<!-- Preload the hero image (so it starts downloading with the HTML) -->
<link rel="preload" as="image" href="/hero.webp"
      fetchpriority="high" imagesrcset="/hero.webp 1x, /hero@2x.webp 2x">

<!-- Preconnect to origins you'll hit immediately -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin>

<!-- The LCP image itself: priority loading, no lazy attribute -->
<img src="/hero.webp" alt="" fetchpriority="high" decoding="async"
     width="1200" height="600">

<!-- DON'T lazy-load above-the-fold images — defers LCP -->
<img src="/below-the-fold.webp" loading="lazy" decoding="async"
     width="800" height="400">
  • Inline critical CSS in <head>, defer the rest. Avoids render-blocking stylesheet round-trips.

  • Serve LCP image in modern format (AVIF/WebP) and right size. Next.js <Image> handles this; Astro/SvelteKit have equivalents.

  • Move the hero out of client-side render. SSR/SSG = HTML already contains it; SPA = blank screen until JS hydrates.

  • Cache static assets aggressively (1y immutable). Use a CDN. Origin TTFB is part of LCP.

Improving INP (replaces FID since 2024)

// INP is dominated by long tasks (> 50ms) on the main thread during input.
// Three levers: less work, smaller work, smarter scheduling.

// 1) Break long tasks with scheduler.yield() or setTimeout(0)
async function processMany(items) {
  for (const item of items) {
    handle(item)
    if ('scheduler' in window && 'yield' in scheduler) {
      await scheduler.yield()    // yields to user input, then resumes
    }
  }
}

// 2) Defer non-critical work with requestIdleCallback
requestIdleCallback(() => trackAnalytics(), {timeout: 2000})

// 3) Move pure computation off the main thread
const worker = new Worker('/parse.js')
worker.postMessage(largeJson)
worker.onmessage = e => render(e.data)

// 4) Debounce expensive input handlers
const debouncedSearch = debounce(query => api.search(query), 200)
input.addEventListener('input', e => debouncedSearch(e.target.value))

// 5) Use CSS transitions instead of JS animation on the main thread.
//    `transform` + `opacity` are GPU-accelerated; `width`/`top` are not.

Improving CLS

<!-- Always declare width/height on media — reserves space before load -->
<img src="avatar.jpg" width="48" height="48" alt="">
<iframe src="..." width="640" height="360"></iframe>

<!-- Use aspect-ratio for fluid layouts -->
<style>
  .card-image { aspect-ratio: 16 / 9; width: 100%; object-fit: cover; }
</style>

<!-- Preload custom fonts; use font-display: optional to avoid swap shift -->
<link rel="preload" as="font" type="font/woff2"
      href="/fonts/inter.woff2" crossorigin>
<style>
  @font-face {
    font-family: 'Inter';
    src: url('/fonts/inter.woff2') format('woff2');
    font-display: optional;   /* no FOIT/FOUT shift; falls back if slow */
  }
</style>
  • Reserve space for late-arriving content (ads, embeds, banners). Don't inject above existing content.

  • Avoid `display: none` → DOM insert flicker patterns. Render server-side; hide with CSS until ready.

  • Animations: use `transform` and `opacity`. Layout-triggering properties (top, left, width, height) cause shift.

Measuring

// Field data > lab data. Use the web-vitals library to capture real users.
import {onLCP, onINP, onCLS} from 'web-vitals'

onLCP(metric => send('LCP', metric.value))
onINP(metric => send('INP', metric.value))
onCLS(metric => send('CLS', metric.value))

// Tools cheat-sheet:
//   Lighthouse        — lab synthetic, good for diffs but optimistic
//   PageSpeed Insights — Lighthouse + CrUX field data
//   Chrome DevTools    — Performance panel, breakdown by long tasks
//   web-vitals.js      — ship to your analytics for p75 trend
//   Search Console     — GSC > Core Web Vitals — Google's view of your URLs
Performance Optimization

Bundle Size, Rendering & React Performance

Bundle Size, Rendering & React Performance Code-splitting, lazy loading, render minimisation, and the React-specific patterns that come up in audits. Code-Split

Bundle Size, Rendering & React Performance

Code-splitting, lazy loading, render minimisation, and the React-specific patterns that come up in audits.

Code-Splitting & Lazy Loading

// Dynamic import — splits into a separate chunk
const HeavyChart = lazy(() => import('./HeavyChart'))

<Suspense fallback={<Spinner />}>
  <HeavyChart data={data} />
</Suspense>

// Route-level splitting (built into Next.js, Remix, TanStack Router).
// Don't ship the admin bundle to anonymous visitors.

// Prefetch on intent — start downloading the chunk when the user hovers
<Link to="/dashboard" onMouseEnter={() => import('./pages/Dashboard')}>
  Dashboard
</Link>

// Conditionally import heavy libraries — only when needed
async function exportPdf(data) {
  const {default: jsPDF} = await import('jspdf')  // 200KB, not in main bundle
  new jsPDF().text('Report', 10, 10).save()
}

Bundle Analysis

# Next.js — built-in analyzer
ANALYZE=true pnpm build
# Opens treemap of every chunk. Look for:
#   - Duplicate libraries (lodash + lodash-es, multiple date libs)
#   - Heavy dependencies in client chunks (zod schema in client, etc.)
#   - Polyfills bloating modern browsers

# Vite / Rollup
pnpm add -D rollup-plugin-visualizer
# Adds stats.html after build

# Webpack
pnpm add -D webpack-bundle-analyzer

# CLI tools
npx source-map-explorer dist/**/*.js
npx bundlephobia <package-name>     # before adding a dep

# Common wins:
#   - date-fns → import {format} from 'date-fns/format' (tree-shakeable)
#   - lodash → lodash-es with named imports, or write the helper yourself
#   - moment.js → swap for date-fns or dayjs (~100KB saved)
#   - icon libraries → import individual icons, not the whole pack

React Render Optimisation

// React 19 + the React Compiler auto-memoize — much of the below is then
// redundant. Audit your project's compiler status first.

// 1) Memoize expensive computations
const sorted = useMemo(
  () => items.toSorted((a, b) => b.score - a.score),
  [items]
)

// 2) Memoize callbacks passed to memoized children
const handleClick = useCallback((id) => onSelect(id), [onSelect])

// 3) Wrap pure components with React.memo
const Row = memo(function Row({user}) { return <li>{user.name}</li> })

// 4) Keys must be stable and unique — never the array index for dynamic lists
{items.map(it => <Row key={it.id} user={it} />)}

// 5) Split state — local state shouldn't trigger global re-renders
//    Don't put input value in the top-level store; keep it in the component.

// 6) Lift state DOWN. The component that owns state re-renders.
//    Move state into the leaf if no sibling needs it.

// 7) Use the React DevTools Profiler.
//    'Why did this render?' tells you the changed prop.

Image Optimisation

// Next.js — handles format, sizes, lazy loading, blur placeholder
import Image from 'next/image'

<Image
  src="/hero.jpg"
  alt=""
  width={1200}
  height={600}
  priority             // = preload + fetchpriority high. Above-the-fold only.
  placeholder="blur"   // tiny base64 blurred preview
  sizes="(max-width: 768px) 100vw, 1200px"
/>

// Vanilla HTML — responsive srcset
<picture>
  <source type="image/avif" srcset="hero.avif 1x, hero@2x.avif 2x">
  <source type="image/webp" srcset="hero.webp 1x, hero@2x.webp 2x">
  <img src="hero.jpg" alt="" width="1200" height="600"
       loading="lazy" decoding="async">
</picture>

// Targets to remember
//   AVIF / WebP    — 30-50% smaller than JPEG for the same quality
//   Hero images    — ≤ 200KB on mobile, ≤ 400KB on desktop
//   Lazy-load      — everything below the fold (loading="lazy")

Caching & Network

  • Long-lived immutable cache for hashed assets: Cache-Control: public, max-age=31536000, immutable

  • Short cache + stale-while-revalidate for HTML/JSON: max-age=0, s-maxage=60, stale-while-revalidate=600

  • Prefetch only on intent (hover/focus). Predictive prefetch on every link wastes data.

  • HTTP/3 + Brotli on the CDN edge. Most platforms (Vercel, Cloudflare) ship this by default — verify in DevTools.

  • Service Worker can absorb repeat traffic. See the PWA notes for cache strategies.

Keep your Performance Optimization knowledge sharp.

Save this stack to your personal DevRecall — add your own notes, track what you're learning, and share what you know with the community.

Get started — free forever