Crystallize logo

Storefront performance: Core Web Vitals, images and prefetching

A fast Crystallize storefront mostly comes from a few choices: images served straight from the Crystallize CDN at the right size, one high-priority image per page, cached Discovery reads, servers close to the API, and prefetching on intent. This page covers each, measured against Core Web Vitals.

Core Web Vitals are three measurements of what a shopper feels: LCP (Largest Contentful Paint, how soon the main content shows), INP (Interaction to Next Paint, how quickly the page responds to a tap or click) and CLS (Cumulative Layout Shift, how much things jump). Search engines use them as a ranking signal. On a storefront, LCP is usually the product or hero image, INP is the buy box and filters, and CLS is images and late banners.

Images

Crystallize generates every image at a ladder of widths, in AVIF, WebP and (for JPEG and PNG sources) the original format, and serves them from its CDN. The work is done before the request. The storefront's job is to pick the right file.

  • Use WebP, not AVIF. On Crystallize's current encoder settings WebP is smaller than AVIF for almost every image, by about a quarter on average. Measure on your own images before you change this.
  • Don't put another image optimizer in front. next/image and other framework pipelines re-encode a file that is already encoded, add a hop and bill you for it. Point <img> straight at the Crystallize CDN.
  • Build `srcset` from the variants the API returns for that image. The ladder is capped at the source's own width, so it differs per image. Never compose variant URLs yourself.
  • Always give `sizes`. Without it the browser assumes the image fills the viewport and downloads the largest file.
  • Ask only for what you render. On Discovery, variants(types: "webp", maxWidth: 768) trims a listing's payload on the server.
type CrystallizeImageData = { url: string; altText?: string | null; variants?: { url: string; width: number; height?: number | null }[] | null };

export function CrystallizeImage({ image, sizes, priority = false, hidden = false }: { image?: CrystallizeImageData | null; sizes: string; priority?: boolean; hidden?: boolean }) {
  if (!image) return null;
  // Variants have no format field: pick WebP by extension, one per width.
  const webp = [...new Map((image.variants ?? []).filter((v) => v.url.endsWith('.webp')).map((v) => [v.width, v])).values()].sort((a, b) => a.width - b.width);
  const largest = webp.at(-1);
  return (
    <img
      src={largest?.url ?? image.url}
      srcSet={webp.length ? webp.map((v) => `${v.url} ${v.width}w`).join(', ') : undefined}
      sizes={sizes}
      width={largest?.width}
      height={largest?.height ?? undefined}
      alt={image.altText ?? ''}
      loading={priority ? 'eager' : 'lazy'}
      fetchPriority={priority ? 'high' : hidden ? 'low' : undefined}
      decoding="async"
    />
  );
}

url is the original upload and is the fallback while renditions are still being generated. Variants are generated after upload, and Discovery captures them when the item is indexed. An item published before its renditions existed keeps an empty list until it is published again.

LCP: get the main image early

  • One high-priority image per page, two at most. Give the LCP image loading="eager" and fetchpriority="high", and every other image loading="lazy". Several "high" images compete with each other and nothing is faster.
  • Put it early in the HTML. The browser's preload scanner finds what is in the first bytes. A large header and mega menu ahead of <main> can push the LCP image hundreds of kilobytes down. Stream the hero first, or preload it in <head> with <link rel="preload" as="image" imagesrcset imagesizes>.
  • Preconnect to the media host. Add <link rel="preconnect" href="https://media.crystallize.com"> (or your custom media domain), so the connection is ready when the image is requested.
  • Hidden images get `fetchpriority="low"`. Mega-menu thumbnails that are in the HTML but not on screen should never compete with the page's own image. Better still, render them only when the menu opens.

CLS: reserve the space

  • Every <img> has width and height (from the variant), so the browser reserves the space before the file arrives.
  • Load fonts with font-display: swap and a size-adjusted fallback, so text doesn't reflow when the font lands.
  • Personal content that streams in (a "shopping as" badge, a ranked rail) gets a fallback of the same size.

INP: keep interactions light

  • Update the cart optimistically. Show the new line at once and replace it with the Shop API's answer, which takes a few hundred milliseconds.
  • Debounce search as you type (about 120 ms), abort the previous request, and drop hits with a score of 0.
  • Keep long work off the main thread. Large client components on the product page delay every tap.

Server and network

  • Run your functions next to the Crystallize API. A page makes several sequential API calls, and each one pays the distance. For EU tenants a European region (for example fra1 on Vercel) instead of a US default cut every call's round trip.
  • Cache Discovery reads and refresh them on publish. See Caching and revalidation.
  • Skip `facets` where no filters are shown. Rails, menus and carousels don't need them, and they are a large part of a listing's response.
  • Prefetch on intent, not on sight. Prefetching every product card that scrolls into view costs a server render per card. Prefetch the full page when the shopper hovers, focuses or touches a link. A hover comes a few hundred milliseconds before the click, which is about what the prefetch takes.
  • Render on demand. Prerender the sections and the most visited pages, and let product pages render on first visit and stay cached. Building every page ahead calls Discovery thousands of times per deploy. If you do prerender everything, cap the concurrency.

Measure in the field

Lab tools show one device on one network. Measure real shoppers with the web-vitals library (its attribution build tells you which element was the LCP and which interaction was slow) and send the numbers to your own endpoint:

import { onCLS, onINP, onLCP } from 'web-vitals/attribution';

function send(metric: { name: string; value: number; id: string; rating: string }) {
  navigator.sendBeacon('/api/vitals', JSON.stringify({ name: metric.name, value: metric.value, id: metric.id, rating: metric.rating, path: location.pathname }));
}

onLCP(send);
onINP(send);
onCLS(send);

Small things that cost time

  • Ship a real /favicon.ico. Browsers and crawlers ask for it, and without it the request can fall into a catch-all route.
  • Declare the colour scheme the design supports (<meta name="color-scheme" content="light"> for a light-only design), so form controls and forced dark modes match the page.
  • Provide a web app manifest if the store should be installable.
tip

See it live

The Tools Universe and Photo Store demos serve images straight from the Crystallize CDN with WebP srcset, and prefetch product pages on hover. The full background on image formats and widths is in the responsive-images skill of the Crystallize agent skills.