Frontend Handbook
Written

Performance

Core Web Vitals, HTML delivery and CDNs, the critical path, images, fonts, code splitting and web workers, each with a live demo.

Web performance is how fast a page loads and how quickly it responds to the user. Slow pages lose people: they leave before the content shows, give up on a form that lags, or tap the wrong button when the layout jumps. This page covers the ideas that matter most, each with a demo you can measure in your own browser.

Based on web.dev's Learn Performance (opens in a new tab) course, shortened to its main concepts.

01

Why speed matters

Speed is part of the experience, and it's measured from the user's side. Google's Core Web Vitals name the three things users feel most, each judged at the 75th percentile of real visits.

  • LCP (loading): when the largest image or text block appears. Good is 2.5 s or less.
  • INP (responsiveness): the delay from a click, tap or key press to the next paint, for the slowest interaction. Good is 200 ms or less.
  • CLS (visual stability): how much visible content moves unexpectedly. Good is 0.1 or less.
  • Lab vs field: Lighthouse and DevTools test one load on your machine (lab); real users on slower phones and networks give field data (CrUX, the web-vitals library). Fix with lab tools, judge with field data.

Try it: this page's own vitals

  • LCPCore Web Vital

    Largest Contentful Paint

    Waiting for the largest paint…

    Good ≤ 2.5 s

  • INPCore Web Vital

    Interaction to Next Paint

    Click, tap or press a key anywhere to measure

    Good ≤ 200 ms

  • CLSCore Web Vital

    Cumulative Layout Shift

    No shift yet

    Good ≤ 0.1

  • FCP

    First Contentful Paint

    Waiting for the first paint…

    Good ≤ 1.8 s

  • TTFB

    Time to First Byte

    Waiting for the load…

    Good ≤ 800 ms

Measured in your browser for this tab. One visit is a single sample; real thresholds apply to the 75th percentile of many visits. Development builds are slower than production.

  • Do: Collect field data from real users (web-vitals → your analytics) and watch the 75th percentile.

    Avoid: Judging speed by a single Lighthouse run on a fast laptop.

Read more on web.dev:Why speed matters(opens in a new tab)Web Vitals(opens in a new tab)

02

HTML delivery and CDNs

Every page starts with one HTML request, and nothing else can start before its first bytes arrive. Time to First Byte (TTFB) covers redirects, DNS, the connection and the server's work; good is 0.8 s or less.

  • Avoid redirects: each one is another request, often on a new connection (http → https, a missing www, a trailing slash). Link to the final address.
  • Cache HTML briefly: a short Cache-Control lifetime plus ETag or Last-Modified lets browsers and CDNs revalidate cheaply. Never cache personalised pages in a shared cache.
  • Compress text: Brotli (about 15–20% smaller than gzip) with gzip as a fallback; pre-compress static CSS and JS at build time, compress HTML on the fly.
  • Use a CDN: edge servers near your users cut every round trip, cache static files (and HTML when allowed), and bring HTTP/2–3, TLS 1.3 and compression by default.
  • Measure the server: the Server-Timing header shows where TTFB goes (database, rendering); stream the HTML so the <head> leaves early.

Try it: switch on a CDN, edge caching, compression and a direct link (simulated)

A visitor 200 ms away from your server · Far away, no CDN

Time to first byte (TTFB)
1,450 ms
Over the 800 ms “good” limit
HTML fully downloaded
1,642 ms
Simulated request timeline
  1. DNS lookup · Find the server's address.50 ms
  2. Connect (TCP + TLS) · Two round trips to a far-away server.400 ms
  3. Redirect · http → https://www: one more request plus a new connection.600 ms
  4. Request + server time · The origin builds the page for every visitor.400 ms
  5. Download HTML · 120 KB uncompressed.192 ms
  • Do: Give hashed static files a long max-age (a year, immutable) and HTML a short one with revalidation.

    Avoid: Caching pages that include user data at the CDN: one visitor's page can be served to another.

Read more on web.dev:General HTML performance(opens in a new tab)

03

The critical path and resource hints

Before the first paint the browser needs the HTML, then every stylesheet and every classic script in <head>: this chain is the critical rendering path. Shorten it, and help the browser find important files early.

  • The clock starts with the HTML itself (see HTML delivery and CDNs); everything below can only begin once it arrives.
  • CSS blocks rendering and classic scripts block parsing (see How the browser renders a page). Use defer (or async for independent scripts), and inline the small CSS needed above the fold.
  • The browser can only fetch what it has found. preload resources it finds late (CSS backgrounds, fonts), preconnect to the one or two other origins you will need right away, and use the cheaper dns-prefetch for origins needed later.
  • fetchpriority="high" tells the browser which image is the LCP, so it isn't queued behind less important ones.

Try it: toggle each technique and watch first and largest paint (simulated)

Techniques · Unoptimized

First paint (FCP)
870 ms
Largest paint (LCP)
1,340 ms
Web font ready
1,270 ms
Simulated waterfallConnectDownloadRun scriptPaint milestones
  1. index.htmlrender-blockingDownload 0–450 ms.

    The document: nothing renders before it arrives.

  2. styles.cssrender-blockingDownload 320–570 ms.

    Stylesheets block rendering until they arrive.

  3. app.jsrender-blockingDownload 320–670 ms, Run script 670–820 ms.

    A classic script in <head> stops the parser until it has downloaded and run.

  4. fonts.example.com/inter.woff2Connect 820–1120 ms, Download 1120–1270 ms.

    A new origin needs DNS, TCP and TLS first; text shows in a fallback font until then (font-display: swap).

  5. hero.avif (LCP)Download 820–1320 ms.

    Found only once styles are computed (CSS background); low priority: shares bandwidth.

  • Do: Preload or preconnect only the one or two resources the first paint needs.

    Avoid: Preloading everything: each hint competes for the same bandwidth, so nothing comes first.

  • Do: Use defer for scripts that need the DOM; keep the order they are written in.

    Avoid: Putting classic <script src> tags in <head> without defer or async.

Read more on web.dev:Critical path(opens in a new tab)Resource loading(opens in a new tab)Resource hints(opens in a new tab)

04

Images, video and lazy loading

Images and video are usually the largest bytes on a page, an image is often the LCP element, and media that arrives late is the usual cause of layout shift.

  • Send the right size: srcset and sizes let the browser pick a file for the screen. Prefer AVIF or WebP.
  • Always set width and height (or CSS aspect-ratio) so space is reserved before the image arrives.
  • Lazy-load images and <iframe>s below the fold with loading="lazy", but never the LCP image (see practice 11).
  • Heavy embeds (video players, maps, chat widgets): show a light facade (a poster image and a play button) and load the real iframe on click.
  • Video: set a small poster, use preload="none" (or metadata) unless it plays at once, lazy-load it below the fold, and replace animated GIFs with a muted, looping <video>, often several times smaller.

Try it: load an article with and without reserved image space

Press “Load article”. The image arrives 1.2 s after the text.

Layout shift (measured)
—
Text moved
—

Good CLS for a whole page is ≤ 0.1. Watch the CLS card at the top change too.

Responsive images: the right file for this box

srcset="hero-480.avif 480w, hero-800.avif 800w, hero-1200.avif 1200w, hero-1600.avif 1600w" sizes="100vw"

File sizes are examples.

  • Do: Set width/height on every image and give the LCP image fetchpriority="high".

    Avoid: Adding loading="lazy" to the hero image: it starts loading only after layout, which delays LCP.

Read more on web.dev:Images(opens in a new tab)Video(opens in a new tab)Lazy loading images and iframes(opens in a new tab)

05

Web fonts

Text set in a web font can't be drawn in that font until the file arrives. What the reader sees meanwhile, invisible text or a fallback font, is chosen with font-display.

  • swap shows text at once in a fallback font (good for body text); optional uses the font only if it is there almost at once (no late swap, best for CLS); block hides text for up to 3 s.
  • Send fewer bytes: WOFF2 only, subset to the characters you use (unicode-range), one variable font instead of many weights.
  • Find fonts early: self-host them on your own origin, preload the one or two the first paint needs, or preconnect to the font host.
  • Keep the swap from shifting the layout: match the fallback's size with size-adjust and ascent-override (next/font does this for you).

Try it: watch the same text under block, swap and optional (simulated)

The web font arrives after

0.0 sDashed line: the font arrives

  • font-display: block

    Hide text up to 3 s, then fallback, then swap.

    Press “Load page”

  • font-display: swap

    Fallback at once, swap when the font arrives.

    Press “Load page”

  • font-display: optional

    Use the font only if it's there almost at once.

    Press “Load page”

Simulated timing. With optional the font is still downloaded and cached, so the next page uses it. Swapping fonts can shift the layout; matching the fallback's size with size-adjust keeps the shift small.

  • Do: Self-host one or two WOFF2 files, preload the one used above the fold, and use swap or optional.

    Avoid: A render-blocking third-party font stylesheet that pulls six weights before any text can paint.

Read more on web.dev:Web fonts(opens in a new tab)

06

Code splitting, prefetching and prerendering

Every kilobyte of JavaScript is downloaded, parsed and run on the main thread. Ship only what the current page needs, and fetch the rest (or the next page) just before it is needed.

  • Split by route (Next.js does this per page) and by interaction: load a heavy dialog, editor or chart with import() when the user opens it.
  • Prefetch on intent: start loading when the pointer hovers or a link enters the viewport (next/link does this), so the click finds it ready.
  • <link rel="prefetch"> fetches a likely next resource at low priority, for later use.
  • Speculation Rules (<script type="speculationrules">) prefetch or even prerender likely next pages, so they open instantly. Prerendering costs data and CPU, so keep it to links the user is very likely to follow (for example on hover).
  • A service worker can precache the files for the next visit and work offline (Workbox makes this easier).
  • Check what you ship with a bundle analyzer; one large dependency often outweighs everything else.

Try it: load a chart's code only when it's opened

The report's code isn't loaded yet.

You waited after clicking
—
Real chunk download (measured)
—

Includes a simulated 600 ms network delay so the effect is visible on a fast connection. This chunk is tiny; a chart or editor library is often 100 KB or more.

  • Do: Lazy-load code behind an interaction or below the fold, and prefetch it on intent.

    Avoid: Splitting into dozens of tiny chunks that must load one after another for the first paint.

Read more on web.dev:Code splitting(opens in a new tab)Prefetching and prerendering(opens in a new tab)

07

Long tasks and web workers

The main thread runs your JavaScript, styles, layout and paint, one task at a time. A task over 50 ms is a long task: while it runs, clicks and key presses wait, which is what INP measures.

  • Break long work into chunks and yield between them so input and painting can happen in between: scheduler.yield() resumes first (good for input), a new task (setTimeout, MessageChannel) lets rendering go first.
  • Move pure computation (parsing, filtering, image processing) to a web worker. Workers have no DOM access and messages are copied, unless you transfer an ArrayBuffer, which moves it between threads for free.
  • A concrete use case: the course reads Exif metadata from JPEGs in a worker; the demo below blurs a photo there, sending its pixels both ways as a transferable.
  • In React, keep input urgent with useTransition or useDeferredValue and render less (see practice 14).

Try it: blur a photo in one go, in chunks, and in a worker

One long task

All the work in one go on the main thread.

Chunks that yield

Work in ~8 ms slices, each followed by a new task so the browser can paint and handle input.

Web worker

The pixels move to another thread and back; the main thread stays free.

Preparing the photo in a worker…

Main-thread animation: freezes whenever the main thread is busy

Frames painted:

Last run of each mode
ModeTotal timeLongest taskFrames droppedImage checksum
One long task————
Chunks that yield————
Web worker————

Each run blurs a 1200×750 photo (3.6 MB of pixels); all three modes must produce the same image (same checksum). The worker receives and returns the pixels as a transferable buffer, so nothing is copied between threads. Long tasks are tasks over 50 ms, reported by the browser.

  • Do: Profile with the DevTools Performance panel and split or move the longest tasks first.

    Avoid: Running heavy loops in click handlers: the click's own paint waits for them, so INP shows the whole loop.

Read more on web.dev:Web workers(opens in a new tab)A web worker use case(opens in a new tab)Optimize long tasks(opens in a new tab)