EmpireUI
Get Pro
← Blog8 min read#image#optimisation#webp

Image Optimisation in 2026: WebP, AVIF, Lazy Load and LCP

WebP, AVIF, lazy loading, and LCP — everything you actually need to know about image optimisation in 2026, with real code and no hand-waving.

abstract colorful digital image compression layers representing web performance

Why Images Are Still the Biggest LCP Problem

Images account for roughly 55–60% of total page weight on median websites as of 2026, according to HTTP Archive data. That number has barely moved in three years. You'd think by now every dev would have this sorted — and yet here we are, watching 4 MB hero PNGs tank LCP scores on otherwise clean Next.js apps.

LCP — Largest Contentful Paint — measures how long it takes for the biggest visible element to render. Google's threshold for 'good' is under 2.5 s. In practice, if your LCP element is an unoptimised JPEG at full resolution served without a CDN, you're lucky to hit 4 s on a mid-range mobile device. That's not a minor UX inconvenience; it's a Core Web Vitals failure that feeds directly into your search ranking.

Honestly, the frustrating part is that fixing images is not architecturally complex. You don't need to refactor your data layer or rethink your component tree. You need the right format, the right <picture> markup, lazy loading on below-the-fold images, and an explicit width/height on everything. That's it. The devil is just in doing all four consistently.

Worth noting: LCP isn't always an image. It can be a large text block or a background element. But in the vast majority of landing pages — especially the kind you'd build around a design system like Empire UI — it's an <img> or a CSS background. So we'll focus there.

Format Wars: WebP vs AVIF (and When JPEG Still Wins)

WebP landed in Chrome 2010, hit Safari 14 in 2020, and is now universally supported. It gives you roughly 25–34% smaller files than JPEG at equivalent visual quality. That's a meaningful win and you should be using it everywhere that JPEG or PNG was your previous default.

AVIF is the new kid. Based on the AV1 video codec, it achieves 40–60% smaller files than JPEG — sometimes more on photographic content. Browser support crossed the 95% mark globally in late 2025, and in 2026 it's safe to use as your primary format with a WebP fallback. The catch is encoding time: AVIF is slow to encode, which matters if you're doing build-time image generation at scale. sharp v0.33+ handles AVIF reasonably quickly, but expect 3–5× longer encode times vs WebP.

JPEG still wins in one specific case: sequential baseline encoding for progressive rendering on ultra-slow connections, and when you're dealing with legacy CDN pipelines that don't touch format conversion. Look, don't swap JPEG for AVIF without benchmarking — especially if your users are in markets with high 3G usage. Run imagemin-mozjpeg with quality 75–80 and measure; you might already be fine.

PNG stays relevant for screenshots, diagrams, and anything with transparency that needs pixel-perfect edges. For those use cases, switch to WebP-with-alpha or AVIF-with-alpha instead. A lossless WebP is typically 20–30% smaller than a lossless PNG on the same image.

Quick aside: GIF is dead. Animated WebP or, better, a short <video autoplay muted loop playsinline> will be smaller, smoother, and actually accessible. Stop shipping GIFs in 2026.

The `<picture>` Element: Stop Serving the Same File to Every Browser

The <picture> element is how you serve AVIF to browsers that understand it, WebP to the next tier, and JPEG as a universal fallback — without JavaScript and without a server-side content-negotiation header hack. It's been available since 2016 and is still underused.

Here's the pattern you should have burned into muscle memory by now: ``html <picture> <source srcset="/images/hero.avif 1x, /images/hero@2x.avif 2x" type="image/avif" /> <source srcset="/images/hero.webp 1x, /images/hero@2x.webp 2x" type="image/webp" /> <img src="/images/hero.jpg" alt="Hero description" width="1200" height="630" loading="eager" fetchpriority="high" /> </picture> ` The browser picks the first <source> it supports. If nothing matches it falls back to <img>. Notice width and height` are explicit — without those the browser can't reserve space before the image loads, causing layout shift that wrecks your CLS score.

That fetchpriority="high" attribute on the LCP image is easy to miss and genuinely important. It tells the browser to preload this resource ahead of other low-priority network requests. Combined with a <link rel="preload"> in your <head>, you can shave 300–500 ms off LCP on a cold load — numbers that move you from 'needs improvement' to 'good' in Google's thresholds.

In Next.js 14+, the built-in <Image> component handles most of this automatically: it generates WebP/AVIF via the image optimisation API, adds width/height, and sets loading="lazy" by default. Pass priority on your LCP image and you get fetchpriority="high" and a preload link for free. That said, if you're not using Next.js, you're writing this <picture> markup yourself — or you're leaving file size on the table.

Lazy Loading: The Right Way, Not Just `loading="lazy"`

The loading="lazy" attribute has been in all major browsers since 2020. Just add it to every below-the-fold <img> and you defer network requests until the user scrolls near the image. Sounds trivial, but the implementation details matter a lot.

The browser-native approach uses a distance-from-viewport threshold — roughly 1200 px on a fast connection, tighter on slow connections. You don't control that number. For most cases that's fine. Where it breaks down: tall single-page layouts where the browser preloads images 10 viewport-heights below the fold, burning bandwidth for content the user may never reach. In those situations, a JS-based lazy loader with IntersectionObserver and a tighter rootMargin gives you more control: ``javascript const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; if (img.dataset.srcset) img.srcset = img.dataset.srcset; observer.unobserve(img); } }); }, { rootMargin: '200px 0px' } ); document.querySelectorAll('img[data-src]').forEach((img) => observer.observe(img)); `` That 200 px rootMargin is a sweet spot — far enough to avoid visible pop-in, tight enough to not waste bandwidth.

One more thing — never put loading="lazy" on your LCP image. This sounds obvious but it's a surprisingly common mistake in component libraries and templates. If your hero image has loading="lazy", the browser won't start fetching it until it's nearly in the viewport, which directly delays LCP. Set loading="eager" (or just omit the attribute entirely) on any image above the fold.

In practice, a good rule of thumb: the first image in every page template gets loading="eager" fetchpriority="high". Everything below the first screenful gets loading="lazy". If you're building a UI component library — or using one like those at Empire UI — bake these attributes into your image primitives so you can't forget them.

Responsive Images and the `sizes` Attribute

Serving a 1200 px wide image to a 375 px phone screen wastes roughly 10× the bytes needed. The srcset + sizes combo solves this, and it's underused in the same way <picture> is — developers know it exists but don't bother because it 'feels complicated.'

It's not that complicated. srcset lists the image variants and their intrinsic widths. sizes tells the browser how wide the image will render at each breakpoint — before layout is calculated, so the browser can pick the right source while it's still streaming the HTML: ``html <img srcset=" /img/card-400.webp 400w, /img/card-800.webp 800w, /img/card-1200.webp 1200w " sizes=" (max-width: 640px) 100vw, (max-width: 1024px) 50vw, 400px " src="/img/card-800.webp" alt="Card image" width="800" height="600" loading="lazy" /> `` On a 375 px phone, the browser picks the 400 w variant. On a 1440 px desktop where the card is 400 px wide, it picks the 400 w or 800 w variant depending on device pixel ratio. You're no longer serving a 1200 px image everywhere.

Generate your variants at build time. sharp is the standard node-land tool for this — pipe your original through it at widths like 400, 800, 1200, and 2400 px, output WebP and AVIF for each. A basic script: ``javascript import sharp from 'sharp'; const widths = [400, 800, 1200, 2400]; for (const w of widths) { await sharp('src/hero.jpg').resize(w).webp({ quality: 82 }).toFile(public/hero-${w}.webp); await sharp('src/hero.jpg').resize(w).avif({ quality: 60 }).toFile(public/hero-${w}.avif); } `` Quality 82 for WebP and 60 for AVIF are reasonable starting points — AVIF's quality scale is different from WebP's and a q60 AVIF typically looks better than a q82 WebP at a smaller file size.

That said, don't over-generate. Five breakpoint variants per image across 200 images blows up your build cache fast. Target three to four widths, use a CDN with on-the-fly resizing (Cloudflare Images, Imgix, or Vercel's built-in) for anything beyond that, and only do build-time generation for your most critical above-the-fold images.

Measuring What You've Fixed: Tools and Real Numbers

You can't optimise what you don't measure. Lighthouse gives you a quick read on LCP and flags unoptimised images, but it's a simulated lab test — don't treat a green Lighthouse score as 'done.' Real User Monitoring (RUM) via the Web Vitals JS library gives you p75 LCP from actual users on actual devices, which is what Google uses in Search Console.

``javascript import { onLCP, onCLS, onINP } from 'web-vitals'; onLCP((metric) => { // Send to your analytics endpoint navigator.sendBeacon('/api/vitals', JSON.stringify({ name: metric.name, value: metric.value, rating: metric.rating, // 'good' | 'needs-improvement' | 'poor' id: metric.id, })); }); ` The web-vitals` package is tiny (under 2 kB) and gives you the same LCP value Google measures. Integrate it once and you have ground truth from your real traffic.

For build-time analysis, bundlemon can track image sizes across PRs. More useful is a simple shell script that fails CI when any image in public/ exceeds a threshold — something like find public -name '*.jpg' -size +200k -print | grep . && exit 1. Crude but effective; it catches accidental regressions before they ship.

Honestly, the single highest-ROI move if you haven't done any of this yet: run your site through WebPageTest at https://webpagetest.org, set the device to 'Motorola G 4G' and connection to 'LTE', and look at the waterfall. You'll immediately see which image is your LCP element, how late it starts loading, and whether the browser is downloading a 1200 px image for a 375 px screen. Fix those two things first — format conversion and responsive sizing — before touching lazy loading or fetchpriority.

Pulling It Together in a Component

If you're building a React component library or customising components from a system like the glassmorphism components in Empire UI, bake your image best-practices into a reusable <OptimisedImage> component so you can't forget them per-usage. Here's a minimal but complete version:

``tsx interface OptimisedImageProps { src: string; // base path without extension, e.g. '/img/hero' alt: string; width: number; height: number; priority?: boolean; // true = LCP image sizes?: string; className?: string; } export function OptimisedImage({ src, alt, width, height, priority = false, sizes = '100vw', className, }: OptimisedImageProps) { const loading = priority ? 'eager' : 'lazy'; const fetchPriority = priority ? 'high' : 'auto'; return ( <picture> <source type="image/avif" srcSet={${src}-400.avif 400w, ${src}-800.avif 800w, ${src}-1200.avif 1200w} sizes={sizes} /> <source type="image/webp" srcSet={${src}-400.webp 400w, ${src}-800.webp 800w, ${src}-1200.webp 1200w} sizes={sizes} /> <img src={${src}-800.jpg} alt={alt} width={width} height={height} loading={loading} fetchPriority={fetchPriority} className={className} /> </picture> ); } ` You'd call it as <OptimisedImage src="/img/hero" alt="..." width={1200} height={630} priority /> for your LCP image and drop priority for everything else. The component enforces correct loading, fetchpriority`, explicit dimensions, and multi-format sources every time.

One thing this component doesn't handle: placeholder blurring while the image loads. For that you'd add a data-url base64 low-quality placeholder and a CSS transition — but that's a progressive enhancement, not a prerequisite. Ship the above first, measure the LCP improvement, then add blur-up as a second iteration.

Worth noting: if you're on Next.js, the native <Image> component covers most of this already. The custom component above is for vanilla React, Astro, Remix without Vercel's image optimisation, or any stack where you're responsible for your own image pipeline. The concepts are the same either way — format, sizing, priority, and dimensions. Get those four right and your LCP numbers will follow. You can also check out the gradient generator and box shadow generator for other performance-conscious UI tools that don't rely on heavy images at all.

FAQ

Is AVIF safe to use as my primary image format in 2026?

Yes — global browser support is above 95% as of 2026. Use AVIF as your first <source> and WebP as fallback; you'll cover virtually every user without serving a degraded experience to anyone.

Does `loading="lazy"` hurt LCP?

It does if you put it on your LCP image. Never lazy-load the first visible image on a page. Set loading="eager" and fetchpriority="high" on your LCP element; use loading="lazy" on everything below the fold.

What's the quickest win for cutting image file size without a full build pipeline?

Run your images through Squoosh (squoosh.app) or Cloudflare Images. Converting a JPEG to WebP at quality 82 typically cuts file size by 25–35% in under a minute, with no code changes required.

Do I need explicit `width` and `height` attributes on every image?

Yes, always. Without them the browser can't reserve layout space before the image loads, which causes content to jump around — that's Cumulative Layout Shift (CLS), and it's a separate Core Web Vitals failure on top of LCP.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

CSS backdrop-filter: blur, brightness, saturate and When to Use EachGoogle Fonts Performance: font-display, preload and next/fontNext.js Image Optimisation: next/image Deep Dive — Every Prop ExplainedLCP Optimization Guide: Images, Fonts, Server Response and Cache