Web Image Optimization: Formats, Responsive Images, and Lazy Loading
Images are often a page's largest resource, so optimizing them is key to LCP. This guide covers modern formats (WebP/AVIF vs. JPEG/PNG), responsive images (srcset/sizes), Next.js Image, lazy loading, placeholders, and dynamic CDN processing in a production-ready strategy.
Key Points
- Topic Focus: Next.js、React
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Open DevTools, go to the Network panel, and sort by transferred bytes. The resource at the top is almost certainly an image. This is not an illusion: HTTP Archive statistics have repeatedly shown that images account for more than half of the total weight of a typical web page. You celebrate trimming 30 KB from your JavaScript bundle, only for a single uncompressed 2 MB hero image above the fold to push LCP straight to four seconds.
So my position is straightforward: image optimization is the primary battleground for web performance, bar none. It affects LCP more than any JavaScript optimization, and the return on effort is exceptionally high. Changing a few tags, replacing one component, or adding a single CDN parameter can cut traffic by tens of percent. This article explains the entire process, from native HTML to Next.js, and provides a production-ready solution you can apply directly.
1. Why Image Optimization Is LCP Optimization
LCP (Largest Contentful Paint) measures how long it takes to render the largest element above the fold. What is usually the largest element? In nine cases out of ten, it is an image: a hero banner, a large product image, or an article cover. Google's own data shows that more than 70% of LCP elements are images.
This has two implications:
First, your LCP ceiling is largely determined by images. Until the image finishes loading, the LCP element is not considered fully painted. You can reduce TTFB to 100 ms, inline CSS into the HTML, and remove all JavaScript, but if that hero image is still downloading slowly, LCP will not improve.
Second, images are one of the few resources where a small change can produce a disproportionate gain. JavaScript optimization may require refactoring, code splitting, or architectural changes. Image optimization often means changing the format, adjusting dimensions, or adding a few attributes. Converting a 1.2 MB JPEG to AVIF may reduce it to just 180 KB and cut rendering time in half.
Remember this priority: fix images first, then discuss everything else. If a site's images are poorly optimized, chasing 50 ms in JavaScript is focusing on the wrong problem.
2. Choosing a Format: AVIF, WebP, or JPEG/PNG
Format selection is the first step in image optimization. The same image can vary in size by a factor of two to five across formats. Start by understanding these four categories.
JPEG: The established lossy format and the traditional choice for photographs. Its compression ratio is acceptable, and browser compatibility is effectively 100%. Its drawbacks are that it does not support transparency or animation, and modern formats compress much more efficiently.
PNG: Lossless and transparent, making it suitable for icons, logos, and images that need an alpha channel. But PNG files become enormous for photographic content. A photo taken on a phone can easily exceed 10 MB when stored as PNG. Do not store photographs as PNG. This is one of the most common beginner mistakes.
WebP: A modern format developed by Google that supports lossy and lossless compression, transparency, and animation. At equivalent visual quality, it is 25%-35% smaller than JPEG and substantially smaller than PNG. Browser support is now excellent across all mainstream browsers, so WebP can serve as the default format.
AVIF: An image format based on the AV1 video codec and currently the benchmark for compression efficiency. At equivalent quality, it is 50% smaller than JPEG and around 20% smaller than WebP. The tradeoff is slower encoding, roughly 1.5 times as long as WebP, and a lack of support in some older browsers, especially IE and older Safari releases, so it requires a fallback.
My practical recommendation is clear:
Use WebP by default, use AVIF for high-traffic hero and above-the-fold images, and retain JPEG/PNG fallbacks for older browsers.
How do you provide that fallback? Use the <picture> element. The browser checks each <source> in order and selects the first format it can decode:
<picture>
<source srcset="/hero.avif" type="image/avif" />
<source srcset="/hero.webp" type="image/webp" />
<img src="/hero.jpg" alt="Above-the-fold hero visual" width="1600" height="900" fetchpriority="high" />
</picture>
Browsers that support AVIF use the first line. Browsers without AVIF support but with WebP support use the second. Anything that supports neither, which now largely means legacy browsers, uses the final JPEG. The <img> element is the required fallback and the entry point for accessibility and SEO, so alt, width, and height all belong on it.
A common misconception is that the server should inspect the Accept header and return a single format-specific URL. That works, but it pushes the responsibility onto the server. Use <picture> when possible: it is declarative, cacheable, and CDN-friendly.
3. Responsive Images: srcset + sizes Is the Correct Solution
Formats determine how much smaller an image can be. Responsive images determine which dimensions to download. This matters even more than changing formats, yet it is frequently overlooked.
Consider this scenario: you use a 3840×2160 original as the hero image. That resolution is justified on a wide desktop display, but when someone opens the page on a 375 px-wide phone, the browser still downloads the same 4K image. This wastes 90% of the bandwidth and destroys LCP. You cannot send the same image to every device.
The solution is srcset + sizes. srcset lists candidates at multiple dimensions, while sizes tells the browser how wide the image will actually appear at different viewport sizes. The browser then selects the most appropriate candidate.
<img
src="/hero-1600.jpg"
srcset="/hero-640.jpg 640w, /hero-960.jpg 960w, /hero-1280.jpg 1280w, /hero-1600.jpg 1600w, /hero-2400.jpg 2400w"
sizes="(max-width: 768px) 100vw, (max-width: 1280px) 80vw, 1200px"
alt="Above-the-fold hero visual"
width="1600"
height="900"
fetchpriority="high"
decoding="async"
/>
Line by line:
640winsrcsetmeans that the image is actually 640 pixels wide. Thewdescriptor represents width, not pixel density.sizesis essential. It tells the browser that the image occupies the full viewport width (100vw) when the viewport is ≤768 px, occupies80vwbetween 768 and 1280 px, and has a fixed width of 1200 px on larger screens. The browser combines this information with the device's DPR to determine whichsrcsetcandidate to download.- Without
sizes, the browser assumes100vw, so a phone may still download the largest image. This is the most common responsive-image mistake.
Do not generate all these dimensions manually. Use a build tool such as sharp or vite-imagetools, or have the CDN produce them automatically in your pipeline. Upload the original once and generate five dimensions automatically. Write sizes to reflect the real layout. If you are uncertain, use DevTools to measure the rendered width instead of guessing.
4. Next.js Image: Automating Everything Above
Every native technique in the previous sections is correct, but each requires manual maintenance: generating multiple dimensions, writing <picture>, configuring srcset, calculating sizes, and creating blur placeholders. This becomes burdensome as a project grows. Next.js's next/image component automates all of it, and Next.js 16 introduces several important changes you need to understand.
Start with the minimal usage:
import Image from 'next/image'
export default function Hero() {
return (
<Image
src="/hero.jpg"
alt="Above-the-fold hero visual"
width={1600}
height={900}
sizes="(max-width: 768px) 100vw, 1200px"
preload
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQ..."
/>
)
}
It handles the following for you:
- Automatic format negotiation. The Next.js server checks the request's
Acceptheader and returns AVIF when supported, then WebP, and finally the original format. You provide one JPG, and the output is optimized automatically. - Automatic responsive
srcset. It generates a completesrcsetfromdeviceSizes(which defaults to[640, 750, 828, 1080, 1200, 1920, 2048, 3840]) and yoursizesvalue. Note: it generates the complete responsivesrcsetonly when you providesizes; otherwise, it generates only 1x and 2x variants. - Automatic lazy loading. Images use
loading="lazy"by default, so images outside the initial viewport do not compete for bandwidth. - Automatic CLS prevention. You must provide
width/heightor usefill, preventing layout shifts at the source.
Next are the breaking changes in Next.js 16 that matter when upgrading an older project:
Change 1: priority is deprecated in favor of preload. This is the most visible change in v16.0.0. Previously, you added priority to an LCP image. Now you use preload. The semantics are clearer: it inserts <link rel="preload"> into <head>, allowing the browser to start downloading before it encounters the <img> element.
// ❌ Deprecated in Next.js 16
<Image src="/hero.jpg" priority />
// ✅ Correct syntax in Next.js 16
<Image src="/hero.jpg" preload />
The official documentation says that loading="eager" or fetchPriority="high" is sufficient in most cases, so preload is not always necessary. My approach is to use preload for the above-the-fold LCP image and leave every other image alone.
Change 2: qualities must be configured explicitly. Starting with v16, only [75] is allowed by default. To use any other quality value, you must add it to the allowlist in next.config.js:
// next.config.js
module.exports = {
images: {
qualities: [25, 50, 75, 100],
formats: ['image/avif', 'image/webp'],
remotePatterns: [new URL('https://cdn.example.com/**')],
},
}
This is a security hardening measure. It prevents malicious users from supplying arbitrary quality parameters and using your image service as free CPU capacity. If an existing project uses a nondefault quality value and you forget this configuration during a v16 upgrade, requests will return 400 errors.
Change 3: remotePatterns completely replaces domains. domains was already deprecated in v14, and it should not be used at all in v16. Remote images must be configured through remotePatterns in next.config.js. Make the pattern as precise as possible, including pathname and search, rather than using ** for convenience:
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'cdn.example.com', pathname: '/images/**', search: '' },
],
}
Format configuration. The default is formats: ['image/webp']. I strongly recommend enabling AVIF explicitly:
images: { formats: ['image/avif', 'image/webp'] }
Array order matters because the first matching format takes precedence. AVIF encoding is slower, about 50% slower than WebP, but its output is around 20% smaller. Next.js caches each format separately, so the first request may be slower while subsequent cache hits are fast. When self-hosting, make sure the proxy or CDN in front of Next.js forwards the Accept header, or format negotiation will fail. This has caught countless teams off guard.
Using fill. When you do not know the image's actual dimensions, such as when it fills a container, use fill to make it cover its parent. The parent must have position: relative, and you must provide sizes; otherwise, the browser assumes 100vw and may download an unnecessarily large image:
<div style={{ position: 'relative', width: '100%', height: '400px' }}>
<Image
src="/banner.jpg"
alt="Banner"
fill
sizes="(max-width: 768px) 100vw, 1200px"
style={{ objectFit: 'cover' }}
/>
</div>
Static imports. When you import an image and use it as src, Next.js analyzes the file at build time, fills in width/height, and generates blurDataURL automatically for JPG, PNG, WebP, and AVIF. This is the simplest approach for a manageable number of local images:
import hero from '@/public/hero.jpg'
<Image src={hero} alt="Hero" placeholder="blur" sizes="100vw" />
Remote images do not receive the same treatment, so you must generate blurDataURL yourself. Use a library such as plaiceholder on the backend to convert a 10 px thumbnail to base64 and send it to the frontend. Keep it small; a blur placeholder only needs a few kilobytes.
5. Lazy Loading and Placeholders: Do Not Make Users Stare at a Blank Screen
Image optimization is not only about downloading fewer bytes. It should also make the page feel fast. Two techniques matter: lazy loading controls when an image downloads, while a placeholder controls what users see before it arrives.
Lazy loading. Modern browsers support it natively with a single line:
<img src="/photo.jpg" loading="lazy" decoding="async" width="800" height="600" alt="..." />
loading="lazy" tells the browser to begin downloading only when the image approaches the viewport, so offscreen images do not compete for bandwidth. decoding="async" prevents decoding from blocking the main thread. Both attributes are effectively free, so add them where appropriate.
But there is one absolute rule: never lazy-load the LCP image. This is one of the easiest beginner mistakes to make. If you put loading="lazy" on the above-the-fold hero image, the browser waits until it considers the image close enough to the viewport before starting the download, wasting hundreds of milliseconds. Give the LCP image loading="eager" or omit the attribute because eager is the default, and add fetchpriority="high" so it enters the request queue as early as possible.
In Next.js, next/image is lazy by default. The rule is therefore reversed for the LCP image: explicitly use preload to override the default lazy-loading behavior.
Placeholders. Before a lazy-loaded image arrives, its area is blank. With a stable layout, it may appear as a gray region, which still makes the page feel unfinished. A placeholder fills that region so users understand that an image will appear there.
The most effective option is placeholder="blur". It first displays a tiny, heavily blurred image and swaps in the sharp version when loading finishes. The image appears to come into focus, producing a much more polished experience than a blank region:
<Image src={hero} alt="Hero" placeholder="blur" />
Static imports generate blurDataURL automatically; remote images require you to provide it. Another option is a solid-color or CSS-gradient placeholder set through the background style. It is less elegant than a blur, but it costs nothing, requires no extra request, and works well for large image lists.
For a more elaborate treatment, such as a shimmer animation or skeleton screen, replace blurDataURL with a small SVG data URL or wrap the image in a container with a CSS animation. Remember that the purpose of a placeholder is to eliminate the anxiety of a blank screen, not to show off. Do not write 50 lines of CSS solely for a shimmer effect.
6. Dynamic CDN Processing: Move the Image Pipeline to the Edge
At this point, the preceding solutions assume that multiple formats and dimensions are generated at build time. But in many applications, images are user uploads, UGC, or product photos, so build-time processing is impossible. Moving the image pipeline to a CDN for dynamic, on-demand processing is the production-grade solution.
The typical model is to store a single original image and let the CDN crop, convert, and adjust quality through request parameters. Services such as Cloudinary, Imgix, Alibaba Cloud OSS Image Processing, and Tencent Cloud CI commonly use URL parameters like these:
https://cdn.example.com/hero.jpg?w=800&h=600&q=75&format=auto
format=auto makes the CDN inspect the Accept header and return AVIF, WebP, or JPEG automatically. w/h controls dimensions, while q controls quality. The same original can use ?w=1600 on desktop and ?w=640 on mobile, while modern browsers receive AVIF and older browsers receive JPEG. One original can produce any number of outputs.
In Next.js, use a custom loader to direct next/image requests to your CDN:
// next.config.js - disable the default optimization service and use the CDN
module.exports = {
images: {
loader: 'custom',
loaderFile: './image-loader.js',
formats: ['image/avif', 'image/webp'],
},
}
// image-loader.js
'use client'
export default function cdnLoader({ src, width, quality }) {
return `https://cdn.example.com${src}?w=${width}&q=${quality || 75}&format=auto`
}
This preserves next/image behavior for calculating srcset, managing lazy loading, and preventing CLS, while all transcoding happens at CDN edge nodes and consumes no CPU on your Next.js service. This is the recommended architecture for high-traffic sites. A self-hosted Next.js image optimization service becomes a bottleneck and a major cost center as traffic grows. The CDN is where image processing ultimately belongs.
A few practical lessons:
- Caching is critical. CDN-transcoded results should use a long cache lifetime (
Cache-Control: max-age=31536000, immutable). When parameters change, use a new URL or add a version number. Next.js defaultsminimumCacheTTLto 14400 seconds (four hours); for heavily cached content, you can increase it to 31 days. - Do not set quality too high. Values from 70 to 80 are nearly indistinguishable to the eye for photographs, while file sizes differ substantially.
q=75is the Next.js default and offers the best balance. - Do not let users supply arbitrary parameters. If the frontend constructs exposed URL parameters directly, restrict the allowed
w/qvalues. Thequalitiesallowlist in Next.js 16 follows the same principle. Otherwise, someone may use your CDN as a free image-processing API.
7. Pitfall Guide: CLS and Other Seemingly Unrelated Details
Finally, here are several pitfalls that tend to stick only after you encounter them.
Pitfall 1: omitting width/height causes CLS. This is the biggest problem. Without dimensions, the browser does not know how much space to reserve for an image, so it initially lays out the page with zero height. Once the image downloads and its dimensions become known, it abruptly pushes all content below it downward. CLS goes through the roof, and users see the page jump.
There is only one reliable solution: every <img> must have width and height so the browser can calculate its aspect ratio and reserve space in advance. CSS controls the rendered dimensions; width/height declares the ratio:
<img src="/photo.jpg" width="800" height="600" alt="..." />
<!-- CSS controls the rendered dimensions; width/height locks the aspect ratio -->
If you do not want to calculate dimensions, use aspect-ratio:
.hero img { width: 100%; aspect-ratio: 16 / 9; height: auto; }
Next.js requires width/height or fill for next/image specifically to prevent this problem. It is one of the component's greatest benefits.
Pitfall 2: lazy-loading the LCP image. As noted in Section 5, the above-the-fold hero image needs preload + fetchpriority="high", not loading="lazy".
Pitfall 3: failing to configure remotePatterns for remote images. If every remote image suddenly returns 400 after a Next.js upgrade, the most likely cause is that domains was removed and remotePatterns was not configured.
Pitfall 4: the proxy or CDN does not forward the Accept header. You self-host Next.js behind Nginx or a CDN, but every request receives WebP or even the original format because AVIF negotiation no longer works. Ensure that the Accept header reaches the Next.js process unchanged.
Pitfall 5: using fill without sizes. The browser assumes 100vw, causing a phone to download an enormous 3840 px-wide image and damaging both traffic usage and LCP. fill almost always needs sizes.
Pitfall 6: SVG should not pass through the image optimization pipeline. SVG is a vector format that scales losslessly, so optimizing it provides no benefit and can introduce security risks because SVG can embed scripts. In Next.js, use unoptimized for SVG or render it with a normal <img>. Enable dangerouslyAllowSVG only when necessary and configure CSP correctly.
Pitfall 7: an oversized blurDataURL. Some developers embed the entire image as base64 and use it as the placeholder, inflating the HTML. A blur placeholder should be a tiny image around 10 px wide and no more than a few kilobytes.
Summary
- Images are the largest page resources, so optimizing images is effectively optimizing LCP. Images make up 70% of LCP elements. Fix them before optimizing JavaScript so you do not focus on the wrong problem.
- Format selection follows a clear order: AVIF > WebP > JPEG/PNG. Use WebP by default, AVIF for high-traffic above-the-fold images, and
<picture>to retain a JPEG fallback for older browsers. In Next.js, configureformats: ['image/avif', 'image/webp']. - Responsive images depend on both
srcsetandsizes. Withoutsizes, the browser assumes100vw, and phones may still download enormous files. Whenever you usefillor a responsive layout in Next.js, providesizes. - Next.js 16 has three essential changes: replace
prioritywithpreload, explicitly configure thequalitiesallowlist, and useremotePatternsinstead ofdomains. Check all three before upgrading an older project. - Missing dimensions are the root cause of image-related CLS. Every image needs
width/heightoraspect-ratio. Do not lazy-load the LCP image, and remember to forward theAcceptheader when self-hosting. High-traffic sites should move transcoding to a CDN instead of making the Next.js service absorb the CPU cost.
In one sentence: image optimization is not an obscure exercise in shaving off a few kilobytes; it is a system spanning formats, dimensions, loading timing, and edge processing. Internalize that system, and both your LCP and your users' data bills will improve.
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
How Should a Technical Community Design Its Tag System and Recommendation Feed?
Centered on independent development, Next.js technology trends, engineering practices, and the indie developer perspective.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.