Image and Asset Optimization
- nextjs
- react
- performance
- webdev
- frontend
- javascript
- typescript
- softwareengineering
In Internationalization (i18n) in Next.js, we dealt with content that changes shape depending on who's viewing it. This article deals with content that usually doesn't change at all once it's uploaded, but still causes more production pain than almost anything else in a typical SaaS frontend: images, fonts, and static assets that are easy to ship carelessly and expensive to fix later.
This is part of the React & Next.js Complete Guide series, which started with React Fundamentals for Professionals. Images are usually the single heaviest thing on a page by byte count. They're also the easiest thing to get wrong without noticing: a screenshot dropped straight from a design tool into public/ renders perfectly fine on a fast connection and a large monitor. The problem shows up later, on a phone, on a slower network, or once the marketing team has uploaded a hundred more of them.
I want to be upfront about scope here. This article is about the mechanics of serving images and static assets well: formats, sizing, lazy loading, CDNs, and the Next.js Image component. It's not about Core Web Vitals scoring or Lighthouse audits directly, that's the next article in this series. Asset optimization is one of the biggest levers for those metrics, but treat it as infrastructure work first and a metrics exercise second.
Why unoptimized images are usually the biggest problem on the page
Open the network tab on almost any SaaS marketing site or dashboard and sort by transfer size. Images and fonts dominate the list, usually by a wide margin over JavaScript and CSS combined. That's not a coincidence: a single uncompressed screenshot or product photo can easily outweigh an entire application bundle, and most teams review their JavaScript bundle size far more carefully than their image payload.
The reason this matters beyond raw page weight is that images block layout and delay perceived readiness. A page that renders text instantly but leaves large gray boxes where images are still loading feels unfinished. And if those images shift the layout once they arrive, that shift is visible and jarring for the user. Both problems are fixable with the same discipline: know the dimensions of an image before it loads, and know its actual weight before you ship it.
The fix is a set of habits, not a single tool: choosing the right format for the content, serving the right size for the viewport, deferring anything below the fold, and putting a CDN in front of the result so repeat requests never touch your origin. None of these individually is hard. Skipping all of them at once is how a page ends up shipping ten megabytes of images for what should have been a few hundred kilobytes.
Format selection: AVIF, WebP, and knowing when JPEG or PNG is still correct
Modern image formats exist because JPEG and PNG were designed for a different era of displays and compression research. AVIF and WebP both typically produce smaller files than JPEG at equivalent visual quality for photographic content, and WebP also supports the same lossless and alpha-transparency use cases PNG covers, usually at a smaller size.
That said, format choice depends on content, not just on "always use the newest format":
- Photographs and complex gradients: AVIF first, WebP as the fallback, JPEG as the last resort for very old clients.
- Screenshots, UI mockups, or images with sharp text and flat colors: PNG or WebP in lossless mode compress this content better than JPEG, which introduces blocky artifacts around hard edges.
- Simple icons, logos, and illustrations with a handful of colors: SVG, if the source is vector, beats every raster format because it scales without quality loss and is usually a few kilobytes at most.
- Animated content: prefer a short looping video (
<video autoplay muted loop>) over an animated GIF. A GIF re-encodes every frame at full color depth with no modern compression; an MP4 or WebM of the same animation is routinely a fraction of the size.
The tradeoff with AVIF specifically is encoding time. AVIF encoders are slower than WebP or JPEG encoders, which matters if you're generating images on demand rather than at build time. For a build-time asset pipeline this is a non-issue. For user-uploaded content processed synchronously on request, it's a real cost worth measuring before you commit to it.
Responsive images: serving the right size, not just the right format
Format optimization alone doesn't help if you're still sending a 2400px-wide image to a 320px-wide mobile viewport. The srcset and sizes attributes exist so the browser can pick the smallest image that satisfies the actual rendered size and the device's pixel density, instead of downloading one fixed asset for every viewport.
<img
src="/images/dashboard-hero-800.jpg"
srcset="
/images/dashboard-hero-400.jpg 400w,
/images/dashboard-hero-800.jpg 800w,
/images/dashboard-hero-1600.jpg 1600w
"
sizes="(max-width: 640px) 100vw, 800px"
alt="Dashboard overview showing revenue and active users"
width="800"
height="450"
/>
The width and height attributes aren't decorative. They let the browser reserve the correct box for the image before it downloads, which is what prevents layout shift once the image arrives. Skipping them is one of the most common causes of a poor cumulative layout shift score on pages that otherwise look fine.
Generating three or four sizes of every uploaded image by hand doesn't scale past a handful of images. That's exactly the problem Next.js's Image component and services like Cloudinary or an AWS CloudFront plus Lambda@Edge pipeline solve: you upload one source image, and resizing happens on demand or at build time.
The Next.js Image component in production
next/image wraps the responsive-image pattern above into a component that also handles lazy loading, format negotiation, and a built-in image optimization API, either through Next.js's own image server or through a configured external loader.
import Image from "next/image";
export function DashboardHero() {
return (
<Image
src="/images/dashboard-hero.jpg"
alt="Dashboard overview showing revenue and active users"
width={800}
height={450}
priority
sizes="(max-width: 640px) 100vw, 800px"
/>
);
}
A few details matter more than they first appear to:
prioritydisables lazy loading for that specific image and gives it a preload hint. Reserve it for the largest above-the-fold image on a page, usually a hero image or the LCP candidate. Marking every image asprioritydefeats the point, since it forces the browser to treat everything as equally urgent.- Without
priority,next/imagelazy loads by default, deferring the request until the image is close to entering the viewport. This is correct behavior for anything below the fold and needs no configuration. - For images loaded from a remote source, such as user avatars stored in S3 or a CDN bucket, you have to explicitly allow that domain in
next.config.jsunderimages.remotePatterns. This is a security boundary as much as a configuration step; it stops the optimization API from proxying arbitrary external URLs.
// next.config.js
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "assets.example-cdn.com",
pathname: "/uploads/**",
},
],
formats: ["image/avif", "image/webp"],
},
};
module.exports = nextConfig;
The tradeoff with the built-in Next.js image optimization API is where the resizing actually happens. Run it on Vercel and it's handled for you as part of the platform. Self-host on a Node.js server or in a container on AWS, and every unique image size request runs through the same Node process serving your application, competing for CPU with everything else that process does. At meaningful traffic, that's usually a reason to push resizing to a dedicated image CDN (Cloudinary, imgix, a CloudFront distribution backed by a Lambda@Edge resizer) rather than let the app server do it inline.
Fonts, icons, and other static assets
Images get most of the attention, but web fonts are a close second in avoidable page weight. They're also a much more common cause of layout shift, because text using a custom font renders invisibly, or with a fallback font that reflows the page, until the font file arrives.
A few practices consistently help:
- Self-host fonts instead of loading them from a third-party font service where reasonable. This removes an extra DNS lookup and TLS handshake, and it puts the font under your own caching and CDN policy instead of a third party's uptime.
- Subset fonts to the character sets you actually use. A font file with the full Latin, Cyrillic, and Greek character sets is far larger than one subsetted to just the Latin characters your product actually renders.
- Use
font-display: swap(or Next.js's built-in font optimization vianext/font) so text renders immediately in a fallback font rather than staying invisible while the custom font downloads.next/fontalso handles subsetting, self-hosting, and injecting the rightfont-displayvalue without manual configuration. - Preload the exact font file used above the fold with a
<link rel="preload">hint, but only for that file. Preloading every font weight and style you have in the project just moves the download earlier without reducing total bytes, and can actively delay more important requests competing for the same connection.
SVG icons deserve one specific caution: an inline SVG sprite sheet with hundreds of icons bundled into your JavaScript adds to your bundle size even for icons a given page never renders. An icon component library that tree-shakes unused icons, or a build step that only inlines icons actually imported, avoids paying for icons you don't use on that page.
CDN strategy and cache headers for static assets
None of the format or sizing work matters much if every request for an asset still has to travel to your origin server. Static assets, images, fonts, compiled JavaScript and CSS, should sit behind a CDN with aggressive, long-lived cache headers, because their content genuinely doesn't change once built.
// Example: setting cache headers for a static asset route in an Express/NestJS app
res.setHeader(
"Cache-Control",
"public, max-age=31536000, immutable"
);
The immutable directive combined with a one-year max-age is safe precisely because the filename changes when the content changes. Next.js's build output already does this by hashing filenames for JavaScript and CSS chunks; the same principle applies to images if you're hosting them through a pipeline that fingerprints filenames on upload rather than reusing a stable name like hero.jpg for every new version of that image.
On AWS, this typically means CloudFront in front of an S3 bucket holding the actual asset bytes, with cache invalidation handled through fingerprinted filenames rather than manual CloudFront invalidations, which are slower and cost more at volume than simply uploading a new file under a new key.
Production pitfalls worth knowing before they bite
A few mistakes show up repeatedly once an app has real traffic and real content editors uploading images:
- Serving the original uploaded resolution everywhere. A user uploads a 4000px photo for their profile avatar, and the app renders it at 48px without ever resizing it server-side. The browser still downloads the full 4000px file even though
widthandheightare set to 48, because HTML dimension attributes only affect layout, not what gets fetched, unless a responsivesrcsetor an optimization pipeline actually generates a smaller version. - Forgetting
alttext, which is both an accessibility failure and a missed opportunity, since alt text is one of the few pieces of image metadata search engines can actually read. - Treating the image optimization API as free at scale. Every unique combination of source image and requested size is a distinct resize operation the first time it's requested. A page that generates size variants dynamically based on unpredictable query parameters can end up computing far more unique variants than intended, each one consuming CPU and cache space.
- Not setting explicit width and height, or an
aspect-ratioin CSS, on images loaded outsidenext/image. This is still the single most common cause of layout shift once a team believes they've "handled" image performance by switching formats but skipped the dimension attributes.
Key takeaways
- Images and fonts are usually the heaviest part of a page's total weight, and they're easy to overlook because they render fine on a fast connection during development.
- Choose format by content type: AVIF or WebP for photos, SVG for vector icons and logos, video instead of animated GIF, and keep PNG or lossless WebP for flat-color screenshots.
- Responsive images (`srcset`, `sizes`, or `next/image`) matter as much as format, since serving one oversized image to every viewport wastes bandwidth regardless of format.
- Always set explicit dimensions or an aspect ratio on images to prevent layout shift, and reserve `priority` loading for the actual above-the-fold image, not every image on the page.
- Self-host and subset fonts where practical, and use `font-display: swap` or `next/font` to avoid invisible text while fonts load.
- Put a CDN with long-lived, immutable cache headers in front of every static asset, and rely on fingerprinted filenames rather than manual cache invalidation.
Frequently asked questions
Should I always use AVIF over WebP?
Not automatically. AVIF usually compresses photographic content smaller than WebP, but its encoders are slower, which matters if you're resizing images on demand rather than at build time. For a build-time pipeline, generate both and let the browser pick via `srcset` or Next.js's format negotiation.
Does next/image eliminate the need for a CDN?
No. It handles resizing and format conversion, but the resulting bytes still need to be cached and served efficiently to users, ideally from an edge location close to them. On Vercel this is built into the platform; self-hosted deployments still need a CDN in front of the app server or a dedicated image service.
Why does my Lighthouse score flag images even though I used next/image?
Usually because `priority` wasn't set on the actual largest contentful paint element, or because the `sizes` attribute doesn't match how the image actually renders at different viewports, causing the browser to fetch a larger variant than necessary.
Is it worth converting an entire existing image library to AVIF immediately?
Usually not as a one-time batch job. Convert on demand as images are requested, or convert opportunistically during a broader content migration, rather than spending a dedicated project re-encoding assets that may never be requested again.
How should user-uploaded images be handled differently from static site assets?
User uploads need server-side validation of file type and size before they're stored, and should be resized into a fixed set of variants at upload time rather than trusting the client to send an appropriately sized file. Static site assets can be processed once at build time since their source never changes.
Do SVGs ever need optimization, or are they always small?
SVGs exported directly from design tools often carry unnecessary metadata, comments, and unminified path data. Running them through an SVG optimizer before shipping them typically reduces their size without any visible change, especially for icons exported in bulk.
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.