Performance Details

8 min

Performance Details

Performance in the craft sense isn't about bundle size audits or Lighthouse scores—it's about the specific, visible regressions that users see: layout shift when an image loads, invisible text while fonts download, a list that drops frames past 100 rows. These are all preventable with a handful of HTML attributes and CSS rules.

Image dimensions for CLS

Every <img> and <video> element needs explicit width and height attributes. Without them, the browser reserves zero vertical space until the image loads. When it arrives, content below jumps—Cumulative Layout Shift (CLS), one of the three Core Web Vitals.

The user is reading paragraph 3 when paragraph 2's image arrives and pushes everything down two screens. It's measurable, it affects search ranking, and it's annoying.

Set the intrinsic dimensions—the actual pixel size of the source image—as HTML attributes. CSS can still scale the rendered size; the browser uses the attributes to compute aspect ratio:

<img src="/hero.jpg" width="1920" height="1080" alt="Sunset over the city" />

Or use aspect-ratio in CSS:

.responsive-img {
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
}

Next.js <Image> requires width and height (or fill) and handles this automatically. For <picture> with <source srcset>, set width and height on the inner <img>—the browser uses those for layout reservation regardless of which source it picks.

fetchpriority and loading

Not all images are equal. The hero image at the top of the page needs to render immediately; the footer logo can wait.

<!-- Above the fold: load immediately -->
<img src="/hero.jpg" width="1920" height="1080" alt="..." fetchpriority="high" />

<!-- Below the fold: defer until near viewport -->
<img src="/screenshot.jpg" width="800" height="600" alt="..." loading="lazy" />

fetchpriority="high" puts the resource at the front of the queue. Use it for the single most important image—usually the LCP element. For Next.js, this is <Image priority />.

loading="lazy" defers the request until the image is near the viewport. Apply liberally to images below the fold, but never to the LCP image—lazy-loading the LCP candidate is a measurable regression.

Font loading: preload, preconnect, font-display

Getting webfonts to the browser fast involves three steps, each targeting a different bottleneck:

1. Preconnect to the font CDN

If fonts come from a different origin, give the browser a head start on the TLS handshake:

<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

2. Preload critical fonts

For the one or two fonts above the fold, preload them so the download starts as soon as the browser parses the <head>:

<link
  rel="preload"
  href="/fonts/inter-regular.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

The crossorigin attribute is essential—without it, the browser won't reuse the preloaded request when the @font-face fires, causing a double-fetch. Preload only the fonts used above the fold. Preloading every weight wastes bandwidth.

3. font-display: swap

Tell the browser what to show during the download. The default (auto) causes FOIT—Flash of Invisible Text—for up to 3 seconds:

@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-regular.woff2") format("woff2");
  font-display: swap;
}

swap shows the fallback font immediately, then swaps in the webfont when it arrives. The visible swap (FOUT—Flash of Unstyled Text) is mitigated by tuning fallback metrics with size-adjust, ascent-override, and descent-override:

@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
}

The closer the fallback metrics match the webfont, the less visible the swap.

Font Loading Strategies
PreviewInvisible text (FOIT)

The quick brown fox

The quick brown fox

Network waterfall
DNS lookup
120ms
TLS connect
180ms
Font download
700ms
Render (FOIT)
0ms1100ms
Time to first visible text1000ms
Time to webfont rendered1000ms
Default font-display: auto — browser hides text for up to 3 s while the webfont downloads. This is FOIT.

Virtualization for large lists

Once a list passes about 50 items, rendering every row at once becomes expensive. The browser lays out and paints every row even if the user only sees ten. Memory grows with row count. Scrolling drops frames.

Virtualization renders only the rows in or near the viewport, recycling DOM nodes as the user scrolls. From the user's perspective, the list scrolls smoothly through 10,000 items. From the browser's, only ~30 rows exist at any moment.

import { useVirtualizer } from "@tanstack/react-virtual";

For shorter lists (50 or fewer), virtualization adds complexity for no gain. Don't reach for it preemptively.

For lists that don't justify full virtualization, CSS content-visibility: auto is a one-line opt-in that lets the browser skip layout and paint for off-screen blocks:

.list-row {
  content-visibility: auto;
  contain-intrinsic-size: 1px 64px;
}

What virtualization breaks: Cmd/Ctrl+F (browser only searches rendered text), anchor links to specific rows, and print views. Plan for these edge cases if they matter for your use case.

Common mistakes

  • Width and height removed because "responsive images don't need fixed dimensions." They need them more. Set intrinsic dimensions; let CSS scale.
  • loading="lazy" on the LCP image. The browser delays loading the most important image on the page.
  • fetchpriority="high" on every image. Defeats the purpose—nothing is high priority if everything is.
  • No font-display in @font-face. Defaults to auto—three seconds of invisible text on slow networks.
  • crossorigin attribute missing on font preload. Double-fetch.
  • Preloading every font weight. Loads a megabyte before the page paints.
  • Virtualizing a list of 20 items. Added complexity for no measurable benefit.