The homepage looked like an obvious image-performance problem.
It was a storefront full of digital planner covers and sticker previews. On mobile, Lighthouse reported a median Largest Contentful Paint (LCP) of 4.04 seconds. The hero image was the LCP element, so the first instinct was predictable: compress the image again.
But the hero was already a 51 KB WebP.
The real problem was everything surrounding it:
- nearly 1 MB of initial HTML and React Server Component data;
- 134 KB of render-blocking CSS;
- authentication and saved-product providers loaded on a public page;
- full product objects serialized for cards that needed only a few fields; and
- an LCP image that was responsive, but not yet expressed to the browser as the page's critical image.
After fixing those issues, the production mobile Lighthouse median dropped from 4.04 seconds to 1.47 seconds. CLS stayed at 0, and median Total Blocking Time was 9 ms.
This article explains the changes that mattered, the changes that were insufficient on their own, and the tests I added to keep the page fast.
Start with the LCP timeline, not the image file
LCP is not just image download time. It can be split into four parts:
- Time to First Byte
- Resource load delay
- Resource load duration
- Element render delay
That distinction matters because shrinking an image only improves the third part. It does nothing when the browser discovers the image late, rendering is blocked by CSS, or the main thread is busy processing unrelated JavaScript.
In my baseline run, Time to First Byte was about 98 ms. The server was not the bottleneck. The 51 KB hero was also not large enough to explain a four-second LCP by itself.
The waterfall pointed elsewhere: the browser was receiving and processing a large document, stylesheet, RSC payload, and client-side code before the hero could become visible.
That changed the optimization question from:
How can I compress this image further?
to:
What is preventing this already-small image from painting sooner?
1. Stop sending detail-page data to catalog cards
The homepage fetched complete product records. A sticker product could contain every sheet in the pack, aliases, high-resolution image URLs, and download URLs.
The card needed only a title, category, thumbnail, price, and a small amount of metadata.
Passing the full objects from a Server Component to a Client Component meant all of that unused data was serialized into the RSC payload.
I added an explicit projection at the server boundary:
export function toHomepageCatalogItem(item) {
const card = {
id: item.id,
type: item.type,
slug: item.slug,
title: item.title,
blurb: item.blurb,
category: item.category,
tone: item.tone,
thumbnailUrl: item.thumbnailUrl,
coverUrl: item.coverUrl,
price: item.price,
};
if (item.type === "planner") {
return {
...card,
year: item.year,
layout: item.layout,
pages: item.pages,
};
}
return {
...card,
sheetCount: item.sheetCount,
};
}
Then the server mapped the API response before rendering the interactive homepage:
const stickerCatalog =
stickerResult.status === "fulfilled"
? stickerResult.value.map(toHomepageCatalogItem)
: [];
return (
<HomePage
planners={planners.map(toHomepageCatalogItem)}
stickers={stickerCatalog}
/>
);
This is easy to miss in image-heavy applications. The images get blamed because they are visible, while oversized JSON and RSC payloads remain invisible.
After projecting the catalog data, the production homepage document fell from roughly 999 KB to 169 KB.
The general rule is simple: serialize for the component you are rendering, not for every component that might render the object later.
2. Push client providers down the route tree
The root layout originally wrapped the entire application with authentication and saved-product state. That was convenient, but it made the public homepage pay for account features before a visitor used them.
The fix was architectural rather than image-specific: keep the root layout server-first and move client providers to the routes that need them.
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
// app/(site)/layout.tsx
export default function SiteLayout({ children }) {
return (
<AuthProvider>
<SavedProductsProvider>
{children}
</SavedProductsProvider>
</AuthProvider>
);
}
The public homepage sits outside that route group, while account-aware pages remain fully functional.
This reduced the client work competing with image decode and paint on slower mobile hardware. It also follows a useful Next.js rule: render providers as deep in the tree as their consumers allow.
3. Make the LCP image discoverable and responsive
Below-the-fold images should usually be lazy-loaded. The LCP image should not.
For the hero, I used next/image with intrinsic dimensions, a realistic sizes value, and the Next.js 16 preload prop:
import Image from "next/image";
<Image
src="/home/hero-digital-planner-ipad.webp"
alt="Digital weekly planner open on an iPad beside a stylus"
width={1402}
height={1122}
sizes="(max-width: 900px) 100vw, (max-width: 1440px) 55vw, 792px"
preload
decoding="async"
/>
Each prop has a different job:
-
widthandheightreserve the correct aspect ratio and prevent layout shift. -
sizestells the browser how wide the image will actually render, allowing it to choose an appropriate candidate fromsrcset. -
preloadtells Next.js that this is the page's critical image.
In Next.js 16, priority is deprecated in favor of preload. Do not combine every urgency signal by habit. Use preload for the one image that is predictably the LCP element, and let non-critical images keep the default lazy-loading behavior.
4. Use image tiers instead of one asset everywhere
An image catalog has at least three different viewing contexts:
| Context | Asset | Goal |
|---|---|---|
| Catalog grid | Thumbnail | Fast scanning |
| Product page | Display image | Clear normal viewing |
| Zoom viewer | High-resolution image | Inspecting details |
Using the original asset in all three contexts wastes bandwidth. Using only a thumbnail makes the product viewer look broken.
I made the media contract explicit:
type CatalogImage = {
thumbnailUrl: string | null;
displayUrl: string | null;
zoomUrl: string | null;
};
Catalog cards request thumbnailUrl. Product pages start with displayUrl. The viewer loads zoomUrl only when the user opens or changes the high-resolution preview.
function previewUrlFor(item, variant = "thumbnail") {
if (variant === "zoom") {
return item.zoomUrl
|| item.displayUrl
|| item.thumbnailUrl
|| null;
}
if (variant === "display") {
return item.displayUrl
|| item.thumbnailUrl
|| null;
}
return item.thumbnailUrl || null;
}
The fallback chain keeps old content usable while new records adopt all three tiers.
For immutable, versioned media objects, the delivery endpoint returns a long-lived cache policy:
Cache-Control: public, max-age=31536000, immutable
Versioned object keys are important here. A year-long cache is safe only when replacing an image creates a new URL instead of mutating the bytes behind an existing URL.
5. Remove render blockers only after proving they block rendering
The baseline included a 134 KB stylesheet that blocked first render. For this application, enabling CSS inlining removed an extra render-blocking request:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
inlineCss: true,
},
};
export default nextConfig;
This is not a universal recommendation.
Inlining can increase HTML size, reduce the caching benefit of a shared stylesheet, and behave differently as an experimental feature evolves. It was useful here because the waterfall showed CSS delaying the LCP render, and the document had already been reduced substantially.
Measure this change against your production route. Do not enable it because an audit contains a generic “eliminate render-blocking resources” suggestion.
6. Keep visual effects away from the critical paint
Image sites often add fade-ins, clip-path reveals, and scale animations to make grids feel polished. These effects can delay when a fully downloaded image is considered painted.
My rule became:
- the hero is visible immediately;
- below-the-fold cards may animate when appropriate;
- image containers reserve space with
aspect-ratioor intrinsic dimensions; -
prefers-reduced-motionremoves non-essential transitions; and - the high-resolution viewer progressively replaces the display image without resizing its container.
A 600 ms entrance animation on the LCP element is still roughly 600 ms added to LCP. The browser does not award style points for making the most important content wait.
7. Add regression tests for performance decisions
Performance regressions often look harmless in code review. Someone passes the complete API object again. A provider returns to the root layout. A hero loses its responsive sizes value.
I added tests for those architectural contracts:
test("homepage catalog projection excludes detail-only payloads", () => {
const projected = toHomepageCatalogItem(largeStickerProduct);
assert.equal(projected.images, undefined);
assert.equal(projected.downloadUrls, undefined);
assert.ok(
JSON.stringify(projected).length
< JSON.stringify(largeStickerProduct).length / 4
);
});
test("account providers stay off the homepage root bundle", async () => {
const rootLayout = await source("../src/app/layout.tsx");
assert.doesNotMatch(
rootLayout,
/AuthProvider|SavedProductsProvider/
);
});
I also test that the hero still uses next/image, sizes, and preload.
These are not substitutes for Lighthouse or real-user monitoring. They protect the decisions that made the measured improvement possible.
Results
I ran multiple Lighthouse tests against the canonical production URL and used the median rather than selecting the best run.
| Metric | Before | After |
|---|---|---|
| Mobile LCP | 4.04 s | 1.47 s |
| CLS | 0 | 0 |
| TBT | — | 9 ms |
| Initial document | ~999 KB | ~169 KB |
Desktop LCP was already around 0.89 seconds before this work. Reporting only the desktop result would have hidden the real user experience on constrained mobile networks and devices.
These numbers are Lighthouse lab results, not field data. The site did not have enough Chrome UX Report data to validate INP or real-user Core Web Vitals, so I did not treat a perfect Lighthouse score as proof that every production user had a perfect experience.
The optimization order I would use again
If I were diagnosing another image-heavy site tomorrow, I would work in this order:
- Measure the production page several times on mobile.
- Identify the actual LCP element.
- Split LCP into TTFB, load delay, load duration, and render delay.
- Inspect HTML, RSC, CSS, and JavaScript before recompressing the image.
- Remove unused data crossing the server-client boundary.
- Scope client providers and third-party scripts to the routes that need them.
- Give the real LCP image intrinsic dimensions, responsive sizing, and early discovery.
- Serve thumbnails, display images, and zoom assets for different contexts.
- Remove measured render blockers.
- Deploy, retest the canonical production URL, and compare medians.
The biggest lesson was not “use WebP” or “lazy-load images.” It was this:
An image can be the LCP element without being the LCP bottleneck.
Optimize the path that gets the image onto the screen, not just the image file.
Top comments (0)