Designing Websites That Feel Fast Before They Actually Load

Quick Summary

  • Perceived speed is as much a design problem as an engineering one, set by the first screen, reserved space and interaction feedback.
  • Preload one font, reserve space with image dimensions and aspect-ratio, and give only the LCP image high fetch priority without lazy loading.
  • Show feedback in the next frame, such as dimming results while a search request runs, and never trade image quality for smaller files.

Nobody experiences a Core Web Vitals score. They experience a page that holds still or one that jumps, a hero that arrives or a gray rectangle, a filter that answers or one that seems to ignore them. That’s why I treat perceived performance as a design problem as much as an engineering one.

Most of what makes a site feel fast is decided before anyone writes optimization code: what the first screen shows, how much space each thing claims, and what happens in the moment after a tap. Here’s how I work through it, one moment of the page load at a time.

First paint: decide what the first screen needs

Before the first screen can look right, the browser needs HTML, CSS and whichever font that screen uses. So the design question comes first: what is the first screen, really? Usually a headline, one image and maybe a button. Everything else can arrive a moment later without anyone noticing.

Fonts are a common place for this to go wrong. A design with four weights across two families doesn’t need all four to paint the hero. Preload the one file the first screen depends on, and let the rest load normally:

HTML
<link rel="preload" href="/fonts/brand-500.woff2" as="font" type="font/woff2" crossorigin>
CSS
@font-face {
  font-family: "Brand";
  src: url("/fonts/brand-500.woff2") format("woff2");
  font-weight: 500;
  font-display: swap;
}

The crossorigin attribute is required even for fonts on your own domain. Fonts are always fetched in CORS mode, so a preload without it doesn’t match the real request and the file is downloaded twice. font-display: swap shows text in a fallback font straight away instead of hiding it. If the swap then makes lines visibly reflow, font-size-adjust or the size-adjust descriptor can bring the fallback closer to the web font’s proportions.

On my own site, exactly one font file is preloaded and every @font-face uses swap. Preloading them all would only reorder the queue, not shorten it.

A loader deserves the same scrutiny. My home page has one that fills a portrait silhouette, and it’s a brand moment, not a performance trick. It earns its place on two conditions: it appears only on the first visit in a browser session, and its image is preloaded so the loader itself never waits on the network. A loader that plays on every page view stops feeling like a welcome and starts feeling like a toll.

Layout: claim the space before you fill it

A page that rearranges itself feels slow even when it isn’t. You start reading, an image lands above the paragraph, and the text jumps away. That’s what CLS, Cumulative Layout Shift, measures, and it’s almost entirely a design and markup problem.

The fix is to reserve space for everything that arrives late. For images, width and height attributes are enough. The browser uses them to work out the aspect ratio before the file arrives, while CSS still controls the rendered size:

HTML
<img src="/uploads/project-1200.webp"
     srcset="/uploads/project-640.webp 640w, /uploads/project-1200.webp 1200w"
     sizes="(max-width: 767px) 100vw, 50vw"
     width="1200" height="800"
     loading="lazy" decoding="async"
     alt="Project overview screen">
CSS
img { max-width: 100%; height: auto; }

.video-embed { aspect-ratio: 16 / 9; }
.card__media { aspect-ratio: 4 / 5; overflow: hidden; }
.card__media img { width: 100%; height: 100%; object-fit: cover; }

In WordPress, wp_get_attachment_image() writes the width and height for you, which is one more reason to render images through it rather than building tags by hand. For embeds, cards and anything whose content loads later, aspect-ratio reserves the box.

This starts in the design file, not the browser. If a card’s image area has no defined shape in Figma, it won’t have one in production either.

The main image: say what matters

The element behind LCP, Largest Contentful Paint, is usually the hero image. Until it has done layout, the browser doesn’t know which image is in the viewport, so it doesn’t give that one special treatment. Tell it:

HTML
<img src="/uploads/hero-1200.webp"
     srcset="/uploads/hero-800.webp 800w, /uploads/hero-1200.webp 1200w"
     sizes="(max-width: 1023px) 100vw, 560px"
     width="1200" height="1565"
     fetchpriority="high"
     alt="Portrait of the founder">

Three rules I follow:

  • fetchpriority="high" on the LCP image only. Priority is relative. If five images are high, none of them is.
  • Never lazy-load above the fold. A lazy image waits until layout confirms it’s near the viewport before it’s even requested, which is exactly the delay you’re trying to remove.
  • Preload only when the image is discovered late, such as a CSS background or an image a script inserts. Then the preload needs the same responsive candidates and the same priority.
HTML
<link rel="preload" as="image"
      href="/uploads/hero-1200.webp"
      imagesrcset="/uploads/hero-800.webp 800w, /uploads/hero-1200.webp 1200w"
      imagesizes="(max-width: 1023px) 100vw, 560px"
      fetchpriority="high">

WordPress core has heuristics for this: it skips lazy loading for the first images it renders and may mark one as high priority. Heuristics guess from rendering order, and a custom hero rendered by a template part is exactly where guesses go wrong. In my theme, images are lazy by default and a template can flag one as above the fold, which drops lazy loading and adds fetchpriority="high". The template knows what’s at the top of the page, so the template should say it. web.dev’s guide to Fetch Priority covers the attribute in more depth.

Don’t buy speed with blurry images

There’s a kind of performance work that makes sites worse. On my own site, an image optimization plugin set to its most aggressive level had re-compressed design images that were already optimized. The photos looked smeared.

The fix wasn’t clever. The originals came back from the plugin’s backups, the plugin was switched to lossless, and WordPress’s resize quality went from its default of 82 to 90:

PHP
add_filter( 'wp_editor_set_quality', function ( $quality, $mime_type ) {
	return in_array( $mime_type, [ 'image/jpeg', 'image/webp' ], true ) ? 90 : $quality;
}, 10, 2 );

The filter only affects sizes generated after it’s in place, so existing thumbnails need regenerating.

The detour is the useful part. The AI agent I was working with first proposed forcing the browser to always load the largest image. That would have hidden the blur by throwing responsive images away. I said no and asked for standard responsive-image practice instead, and with the quality fix, that was enough. A soft photo on a portfolio reads as careless faster than any spinner does, which is the kind of detail I mean in the small UX details that make a website feel premium.

Interaction: perceived performance means answering before the answer arrives

The last moment is the easiest to forget. Someone types in a search box or picks a filter, a request goes out, and nothing on screen changes until it comes back. The page isn’t slow, but it feels broken.

INP, Interaction to Next Paint, measures the time from an input to the next frame the browser paints. The request doesn’t have to finish inside that window. The page only has to show that it heard you. The corollary: nothing expensive should run before that first visual change. Update the UI, then do the work.

My blog’s search and category filter update results without a page reload. The moment a request starts, the current results dim and are marked busy. Typing waits for a short pause before searching, and a newer request cancels the older one:

JavaScript
let controller;

async function loadResults(url, grid) {
  controller?.abort();
  controller = new AbortController();
  const { signal } = controller;

  grid.classList.add('is-loading');       // painted in the next frame
  grid.setAttribute('aria-busy', 'true');

  try {
    const res = await fetch(url, { signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const data = await res.json();
    grid.innerHTML = data.html;           // cards rendered by the same PHP templates
  } catch (err) {
    if (err.name !== 'AbortError') console.error(err);
  } finally {
    if (!signal.aborted) {                // a newer request owns the loading state
      grid.classList.remove('is-loading');
      grid.removeAttribute('aria-busy');
    }
  }
}
CSS
.results { transition: opacity 0.25s ease; }
.results.is-loading { opacity: 0.4; }

Adding a class costs almost nothing, so the next frame paints quickly and already tells the user something is happening. The URL stays in sync too, so Back, Forward and shared links behave like normal pages. Instant feedback does the same job as a pressed state on a button: it isn’t decoration, it’s the interface keeping its promise. That’s the argument in why good UX isn’t about looking beautiful. For the mechanics of the metric, web.dev’s INP guide is the reference.

What I’d take from this about perceived performance

  • Decide what the first screen is, and give it only what it needs first: one font, one image.
  • Give every late-arriving thing a box: width and height on images, aspect-ratio on everything else.
  • Mark the LCP image with fetchpriority="high", and never lazy-load above the fold.
  • Don’t trade image quality for a smaller file. A premium site that looks compressed isn’t premium.
  • Answer every interaction in the next frame, even when the data takes longer.

If the slowness comes from the WordPress stack rather than the design, my older notes on common page speed issues and fixes, deferring JavaScript without a plugin and browser caching headers on Apache are a practical place to start.

Share on X (opens in a new tab) Share on LinkedIn (opens in a new tab) Share on Facebook (opens in a new tab)

ChatGPT (opens in a new tab) Claude (opens in a new tab) Gemini (opens in a new tab) Perplexity (opens in a new tab) Grok (opens in a new tab)

More Insights

See More