Image optimization for the web

Available to registered members only
  • avatar
  • 98 Views
  • 18 mins read

Images are almost always the heaviest thing on a web page. A single hero photo can weigh more than all the HTML, CSS and JavaScript of a site put together, and every extra kilobyte delays the moment a visitor finally sees something useful. Search engines notice it, and users on mobile data notice it faster. The encouraging part is that image delivery is one of the few areas where the fixes are well documented, mostly automatable, and visible in the metrics almost immediately.

What makes images slow

The first cost is the transfer itself, the raw number of bytes that have to travel from the server to the device. A 4000 pixel wide photo straight out of a camera can easily reach 6 MB, and browsers download the full file even when CSS shrinks it to a 400 pixel thumbnail. The resize happens after the transfer, so the user pays for pixels that never appear on screen.

The less obvious cost is everything that happens after the download. The browser has to decode the compressed data into a raw bitmap, and that bitmap lives in memory at four bytes per pixel. That same 4000 by 3000 photo occupies roughly 48 MB of RAM once decoded, regardless of how small the compressed file was. On a mid range Android phone, decoding a handful of oversized images occupies the main thread long enough that scrolling loses frames and the page stops responding smoothly to touch.

Then there is ordering. The Largest Contentful Paint metric usually points at an image, so the time it takes to discover, request and paint that one file often defines how fast the whole page feels. An image buried behind a JavaScript bundle gets requested late, and no amount of compression rescues a request that started two seconds too late.

Picking a format that fits the content

Format choice does more for file size than any compression tweak. The rough division works like this.

  • SVG is the right answer for logos, icons, charts and anything drawn with shapes. It stays sharp at any resolution, it compresses very well with gzip or brotli since it is plain text, and it can be styled with CSS. One caution: SVG uploaded by users can contain scripts, so it needs sanitising before being served.

  • PNG is lossless and supports transparency, which makes it good for screenshots, diagrams with text, and flat graphics with few colours. Used for photographs it produces enormous files.

  • JPEG remains the safe default for photographs. It has no transparency and it degrades on sharp edges and text, but support is universal and encoders like MozJPEG still squeeze meaningful savings out of it.

  • WebP handles both lossy and lossless modes, supports transparency and animation, and typically lands 25 to 35 percent below a comparable JPEG. Every current browser supports it, so it works as the main format rather than a progressive enhancement.

  • AVIF, built on the AV1 video codec, usually beats WebP by another significant margin, especially on photographic content and at low quality settings. It handles wide colour gamut and transparency. The tradeoff is encoding time, which can be an order of magnitude slower, so it belongs in a build step or a CDN rather than in a request handler.

  • Animated GIF deserves special mention because it is the worst offender still in common use. A short looping animation as MP4 or WebM often comes out ten times smaller than the GIF version, and both animated WebP and animated AVIF are reasonable options too.

Serving several formats at once is what the picture element is for.

<picture>
<!-- Browser picks the first type it understands -->
<source srcset="/img/coffee.avif" type="image/avif">
<source srcset="/img/coffee.webp" type="image/webp">

<!-- The img tag is the fallback and carries all the attributes -->
<img src="/img/coffee.jpg" alt="Espresso machine on a wooden counter"
width="1200" height="800">
</picture>

The order matters, since the browser stops at the first source with a MIME type it recognises. Note that the img element is not optional here. It carries the alt text, the dimensions and the actual rendering, while the source elements only describe candidates.

Compression settings worth knowing

Most encoders default to settings that favour quality over size. Pushing quality down to somewhere between 75 and 85 is nearly always invisible on photographic content and cuts the file size dramatically. Below 60 the artefacts start showing on smooth gradients like skies and skin.

Metadata is free weight. Camera EXIF blocks, GPS coordinates, thumbnails and editing history can add tens of kilobytes to every file, and stripping them also removes a privacy problem nobody asked for. The one piece worth treating carefully is the embedded ICC colour profile, which tells the browser how to interpret the stored colour values. Browsers assume sRGB when no profile is present.

A few commands that cover most cases:

# JPEG with MozJPEG, progressive scan and stripped metadata
cjpeg -quality 80 -progressive -optimize -outfile out.jpg in.ppm

# WebP, quality 80 is a good general target
cwebp -q 80 -m 6 -metadata none in.png -o out.webp

# AVIF, lower cq-level means better quality; speed 6 balances time and size
avifenc --min 20 --max 35 -s 6 in.png out.avif

# Lossy PNG palette reduction, often 60 to 70 percent smaller
pngquant --quality=65-85 --strip --output out.png in.png

Progressive JPEG is worth calling out separately. It renders a blurry version early and refines it as more data arrives, which feels faster on slow connections even though the total transfer is the same. For files under roughly 10 KB the baseline variant is usually smaller, so the flag pays off mostly on larger images.

Serving the right size to every screen

A phone at 400 CSS pixels wide with a 2x display needs an 800 pixel image. A desktop hero might need 2400. Sending the same file to both wastes bandwidth on one end and looks soft on the other. The srcset and sizes attributes let the browser choose before the request goes out.

<img
src="/img/post-800.jpg"
srcset="/img/post-400.jpg 400w,
/img/post-800.jpg 800w,
/img/post-1200.jpg 1200w,
/img/post-1600.jpg 1600w"
sizes="(max-width: 700px) 100vw, 700px"
alt="Server rack with patch cables"
width="1200" height="675">

The w values only declare how wide each file actually is. post-800.jpg 800w means that file measures 800 pixels across, nothing more.

sizes answers a different question, how wide the image will be once it is on screen. The browser needs to be told because it starts fetching images while parsing the HTML, before the stylesheets have been applied and before layout has run, so at that moment it has no way to measure the element itself.

The value is a list of media conditions each paired with a width, read left to right until one matches, and the last entry has no condition and acts as the fallback. The example says the image fills the viewport below 700 pixels and sits in a fixed 700 pixel column above that. With the width known, the browser multiplies by the device pixel ratio and takes the smallest candidate that covers the result. Lazy loaded images can skip the guesswork with sizes="auto", since the layout is already known by the time they load, though support for it is still uneven.

Four or five widths per image are normally enough. Generating twenty variants triples storage and cache fragmentation for savings nobody can measure.

Assets that always render at the same size, like avatars, icons or logos, do not need any of that machinery. Their rendered width never changes, so the only variable left is the pixel density of the display. The x descriptors describe exactly that:

<img src="/img/avatar.png"
srcset="/img/avatar.png 1x,
/img/[email protected] 2x,
/img/[email protected] 3x"
alt="" width="48" height="48">

Here 1x means the file matches the CSS size one to one, so avatar.png is 48 by 48 actual pixels. The 2x candidate is 96 by 96 and gets chosen on a standard retina screen, while 3x is 144 by 144 for the denser phone displays. No sizes attribute appears because the browser has nothing to calculate, the element width is already fixed by CSS. Mixing w and x descriptors in the same srcset is invalid, so each image uses one system or the other.

Empty alt text, as in that last example, is the correct choice for decorative images. It tells screen readers to skip the element instead of announcing a filename.

Loading images at the right moment

Lazy loading defers the request for an image until it comes close to the viewport. On a long article with thirty screenshots, that turns thirty immediate requests into three or four. The native attribute needs no JavaScript at all.

<img src="/img/step-7.png" alt="Configuration dialog"
loading="lazy" decoding="async"
width="900" height="540">

Three separate attributes control this behaviour and each one answers a different question.

  • loading decides when the request is made. With eager, the default, the browser fetches the file as soon as it parses the tag. With lazy, it holds the request until the element approaches the viewport. The distance that triggers the fetch is chosen by the browser and varies with connection speed, typically a few hundred pixels up to a couple of thousand on slow networks, so the image is usually ready by the time it scrolls into view.

  • decoding decides how the bytes are turned into a bitmap once they arrive. The default auto lets the browser choose, sync decodes on the main thread and blocks rendering until it finishes, and async moves the work off the critical path so the rest of the page keeps painting. For images that are not the main content of the screen, async is the sensible setting.

The critical rule is to never lazy load anything visible on arrival. Marking the hero image as lazy pushes its discovery to after layout, which reliably makes the Largest Contentful Paint worse. Above the fold images want the opposite treatment, a high priority hint so the browser fetches them ahead of the resources it has queued:

<img src="/img/hero-1200.jpg" alt="Workshop bench"
fetchpriority="high"
width="1200" height="600">

fetchpriority decides where the request sits in the queue. Browsers assign images a fairly low priority by default, behind stylesheets, fonts and scripts. The accepted values are high, low and auto, and they act as a hint rather than a command, nudging the internal scheduler in one direction. Raising priority is only useful in moderation. Marking six images as high priority means the browser has six competing requests and no way to tell which one actually matters, which puts it back where it started.

When the hero lives inside a carousel or gets injected by a framework, the browser cannot find it early in the HTML. A preload hint in the head solves that discovery problem:

<link rel="preload" as="image"
href="/img/hero-1200.jpg"
imagesrcset="/img/hero-800.jpg 800w,
/img/hero-1200.jpg 1200w"
imagesizes="100vw">

CSS background images have no loading attribute, so they need a different approach. An IntersectionObserver watching for elements near the viewport and adding a class is the standard pattern, and it also works for third party embeds like maps and video players that are far heavier than any photo.

Declaring dimensions to avoid shifts

An image without declared dimensions occupies zero height until its bytes arrive, at which point everything below jumps down. That shift is measured by Cumulative Layout Shift and it is the single most irritating thing a slow image can do to a reader who has already started reading.

Declaring width and height on the element fixes it. Modern browsers use those two numbers to compute an aspect ratio and reserve the correct space even when CSS overrides the actual size. The attributes should hold the intrinsic pixel dimensions of the file, and the CSS keeps the responsive behaviour:

img {
max-width: 100%;
height: auto; /* preserves the ratio derived from the attributes */
}

Placeholders make the wait more pleasant. A dominant colour background costs nothing and looks intentional. A low quality image placeholder, a heavily compressed version of a few hundred bytes inlined as a data URI and blurred with CSS, gives an impression of the content before the real file lands. Both are cosmetic rather than performance work, but perceived speed is part of the job.

Using a Content Delivery Network

A content delivery network is a set of servers spread across many locations that cache copies of static assets and serve them from whichever point of presence sits closest to the visitor. For a reader in Barcelona hitting an origin server in Virginia, the round trip alone costs around 100 ms, repeated for every connection setup and every request. An edge node in the same country cuts that to a handful of milliseconds, and the origin stops handling traffic it has no reason to see.

Caching only works when the headers permit it, and the Cache-Control response header is where that permission is written. Its value is a list of directives, each one answering a different question about the stored copy.

  • public and private control who may store the response. public allows shared caches, meaning CDN edges and corporate proxies, to keep a copy and hand it to other users. private restricts storage to the browser of the person who made the request, which is correct for a personalised avatar but pointless for a static photo.

  • max-age sets how many seconds the copy stays fresh in a browser cache. During that window the browser reuses the file with no network request at all. s-maxage does the same for shared caches only and overrides max-age there, which is useful when the edge should hold a file far longer than any individual browser.

  • immutable tells the browser the bytes will never change at this URL, so it should not revalidate even when the user hits reload. Without it, a refresh sends conditional requests for every cached image, and a page with forty of them makes forty pointless round trips.

  • no-cache is frequently misread. It does not prevent storage, it requires the cache to check with the server before reuse, normally through an ETag or Last-Modified comparison that returns a small 304 Not Modified when nothing changed. The directive that actually forbids storage is no-store.

  • stale-while-revalidate allows a cache to serve a slightly expired copy immediately while it refreshes the file in the background, which removes the latency spike that otherwise happens the moment an entry expires.

Content hashed filenames combined with a long lifetime are the reliable combination, because the URL changes any time the bytes change:

Cache-Control: public, max-age=31536000, immutable

That is one year, the practical maximum, and it is safe precisely because app.4f2a91.jpg will never contain different content. For filenames that stay stable across deployments, something like public, max-age=3600, stale-while-revalidate=86400 gives a shorter window with a graceful refresh. Serving images from a separate hostname such as cdn.example.com also keeps cookies off the requests, which trims a few hundred bytes from every single one.

Many CDNs go further and act as image servers, resizing, converting and compressing on the fly from a single original. The transformation lives in the URL or in query parameters, and the result gets cached at the edge like anything else:

https://cdn.example.com/photos/bench.jpg?w=800&fm=avif&q=75

That approach removes the build step entirely and makes new variants free to add. Content negotiation is the other trick worth knowing. A CDN can inspect the Accept request header and return AVIF, WebP or JPEG from the same URL, provided the response carries Vary: Accept so intermediate caches keep the versions apart. Forgetting that header is a classic way to serve AVIF to a client that cannot decode it.

Self hosting is perfectly viable too. On nginx, the image filter module handles resizing without any external service:

location ~ ^/thumb/(?<width>\\d+)/(?<path>.+)$ {
alias /var/www/images/$path;
image_filter resize $width -; # scale to width, keep the ratio
image_filter_jpeg_quality 82;
image_filter_buffer 8M; # reject sources larger than this
expires 30d;
}

Whatever generates the variants, the pattern stays the same. Transform once, cache aggressively, and let the origin sleep.

Automating image processing

Every rule described so far falls apart the moment it depends on somebody remembering it. Optimization done by hand works during the calm weeks and quietly stops during the busy ones, and the images that slip through are usually the ones on the pages that matter most. Moving the work into the build step or the upload handler removes the human from the loop entirely.

A short script covers most static sites. This one takes the originals and writes three widths in two formats:

const sharp = require('sharp');
const widths = [400, 800, 1600];

async function process(input, name) {
for (const w of widths) {
const pipeline = sharp(input).resize({ width: w, withoutEnlargement: true });
// Two formats from the same resized pipeline
await pipeline.clone().webp({ quality: 80 }).toFile(`dist/${name}-${w}.webp`);
await pipeline.clone().avif({ quality: 55 }).toFile(`dist/${name}-${w}.avif`);
}
}

Note the quality numbers are not comparable between codecs. AVIF at 55 looks roughly like WebP at 80, and copying the same value across formats produces either bloated files or visible mush.

Continuous integration closes the gap. A size budget that fails the build when a page exceeds an agreed image weight catches the 8 MB PNG somebody dropped in at 5 pm on a Friday, and it catches it before a reader does. Sites that accept uploads need the same processing server side, since nothing stops a visitor from posting a 20 megapixel original straight from a phone, and the resize has to happen before the file ever reaches storage.

Measuring the results

Guessing is optional here. Lighthouse flags oversized images, missing modern formats and lazy loading mistakes, and it estimates the savings for each one. Chrome DevTools shows the decoded size next to the transfer size, which exposes images that are compressed well but still far larger than their rendered box.

Lab tools have limits though. They run on one machine, on one connection, from one location. Field data from real visitors tells a different and more honest story, and the Chrome User Experience Report or any real user monitoring script will show how the changes land on actual devices. Watch Largest Contentful Paint for the effect of format and priority work, and Cumulative Layout Shift for the effect of declared dimensions.

One habit is worth building. Check the network panel on a throttled mobile profile before shipping any page with significant imagery. Most of the problems described here are visible in ten seconds from that view, long before anyone files a complaint about the site being slow.

Conclusion

Image optimization rewards steady discipline more than clever tricks, and the gains come from a handful of decisions applied consistently rather than from any single technique. None of it requires rewriting an application, most of it can be automated once and then forgotten, and the effect translates directly into pages that keep readers around and behave properly on the modest hardware and patchy connections a large share of visitors are actually using. Set the pipeline up properly, measure the result on real devices, and the topic mostly stops demanding attention.

colored logo

This article is available to HiBit members only.

If you're new to HiBit, create a free account to read this article.

 Join Our Monthly Newsletter

Get the latest news and popular articles to your inbox every month

We never send SPAM nor unsolicited emails

0 Comments

Leave a Reply

Your email address will not be published.

Replying to the message: View original

Hey visitor! Unlock access to featured articles, remove ads and much more - it's free.