DEV Community

RAMO
RAMO

Posted on Edited on

How we cut image weight on a Next.js store product grid without losing quality

Our product grid was the heaviest page on the store. Category pages showed 24 tools at once, and on a mid-range phone over 4G the largest image took a long time to show up. We run our tools store on Next.js, and this is what we changed over a couple of weeks, including one change that made things worse first.

Measuring before touching anything

Before changing a single line we recorded a baseline. We ran Lighthouse on the category page five times with mobile throttling and took the median, then checked field data in the Chrome UX report for the same URL. The lab LCP was clearly too slow, and the page transferred a lot of image data on its own. Without a baseline we would have had no way to tell whether any of the later work mattered.

The sizes attribute does most of the work

We were already using next/image, so we assumed images were fine. They weren't, because we had never set sizes. Without it the browser assumes the image fills the full viewport width, so it picks the largest candidate from the srcset even when the card is a quarter of the screen.

Our grid is two columns on phones, three on tablets and four on desktop with a max container width. So we wrote:

<Image
  src={product.image}
  alt={product.name}
  width={600}
  height={600}
  sizes="(min-width: 1280px) 280px, (min-width: 768px) 30vw, 50vw"
/>
Enter fullscreen mode Exit fullscreen mode

That one prop made the images on the phone much lighter. It was the single biggest win and it took five minutes.

AVIF and WebP

In next.config we enabled formats: ['image/avif', 'image/webp']. AVIF encoding is slow on the first request, so the first visitor to a new product image pays for it. We warmed the cache for our top categories after each deploy with a small script that just requests the main pages' image URLs. AVIF saved us noticeably more over WebP on product photos with plain backgrounds, and less on busy photos of tools in a workshop.

We also trimmed the deviceSizes and imageSizes lists to the widths we actually use. Fewer variants means a better cache hit rate.

Priority only on the first row

Earlier we had put priority on every image in the grid, thinking it meant faster. It does the opposite: it preloads everything and the browser has to fight over bandwidth, so the real LCP image waits in line. Now only the first row of cards (the first two on mobile, four on desktop) gets priority, and everything else lazy loads as default. We pass an index into the card component and check index < 4. It's not elegant, but it works.

The mistake we made

After the above, LCP looked good in the lab but our layout shifted when images loaded. We had removed fixed dimensions in one card variant to make the image "responsive", and CLS jumped from nearly zero to a clearly visible shift. We fixed it by giving the image wrapper an aspect-ratio: 1 / 1 and using fill with object-fit: contain. We only noticed because we kept checking CLS next to LCP instead of celebrating the LCP number.

Also, one batch of supplier photos was uploaded at 4000 px with heavy metadata. Next resizes them, but the first-request cost was ugly, so we now resize at upload time to 1600 px max.

Results

Same method as the baseline, median of five mobile runs: the main image loaded noticeably faster, the grid got much lighter, and layout shift mostly disappeared. Field data took about four weeks to catch up but moved in the same direction.

If you only have an hour, set sizes, limit priority to what is above the fold, and measure before and after. The rest is polish. We're still unsure whether AVIF is worth the encoding cost for a small catalog, so if you have numbers on that we'd like to hear them.

Top comments (0)