1. Presentation
  2. Images
  3. Performance
  4. Image Performance

Presentation · Images 08

Image Performance

Images are most of the bytes on most pages, so they are where performance work pays off first. Fast images come from a short list of habits: send fewer bytes, send them less often, send them when they are needed, and send them from nearby.

The earlier pages taught what an image is, which format suits it, and how to write it into HTML and CSS. This page follows an image from the build step that encodes it, through the attributes on <img>, to the headers and servers that deliver it, and ends with measuring the result and holding the line with a budget.

1. The golden rule

An image used correctly is two things at once: pleasing, the right picture and good to look at, and fast, costing the reader as little time and data as it can. Correct = fast + pleasing. A beautiful image that takes six seconds to appear on a phone is not correct, and neither is one that loads instantly as a smear of compression blocks.

The context decides the balance. A data chart needs crisp text and lines and can lose most of its color subtlety; a photography portfolio is the opposite, because the pictures are the content.

The course states the whole subject as one rule:

“Load less data, less often, and consider carefully when you need it and make sure to fetch it from nearby.”

Shorter: less data, less often, when needed, from nearby. Each clause maps onto a group of techniques, and onto sections of this page:

ClauseWhat it means for imagesSections
Less dataThe right format, the right pixel dimensions, a sensible quality, no metadata, fewer files2, 3, 4, 5
Less oftenCache headers that let the browser keep a file, and file names that change when the file does10
When neededThe hero first, everything below the fold later or never; placeholders while waiting6, 7, 8, 9
From nearbyA CDN edge close to the reader, and an image CDN that resizes and converts there11

The rule is old, and that is the point: pages have grown as fast as networks, and a byte not sent is still the fastest byte. What an Image Costs set out the order of attack (eliminate, reduce the number, reduce the size, load when needed); this page is the toolkit for the last three.

2. Choosing the format is a performance decision

Raster Formats chose formats by what each can represent. The same choice is the largest single performance decision for an image: the wrong format can cost ten times the bytes of the right one, and no quality slider makes up the difference.

The deck asks whether the format choice is clear: photographs are JPEG, drawings are PNG. Then it shows a chart, the flat image of text, lines and a few colors that dashboards are full of, and asks you to spot the difference. The same test with this section's files:

The same 800 by 450 chart, as encoded by the site's build script
FileEncodingBytesHow it looks
chart.pngPalette PNG, lossless6,194Exact: sharp text and edges
chart-same-bytes.jpgJPEG at quality 85,892Smeared text, blocks and halos around every line
chart.jpgJPEG at quality 7519,673Acceptable at a glance, faint halos on close inspection
chart.webpLossy WebP at quality 756,664Close to exact; soft color edges if you zoom
chart.avifAVIF at quality 504,727Close to exact; check thin lines and small text by eye
Bar chart of trail visits by month, as a palette PNG: crisp bars, gridlines and labels.
chart.png, 6,194 bytes.
The same bar chart as a JPEG of almost the same size: the labels are smudged and every edge is surrounded by blotchy artifacts.
chart-same-bytes.jpg, 5,892 bytes: the highest JPEG quality that fits in the PNG's bytes. Apples to apples, and the PNG wins without contest.

This is the deck's "apples to apples" test: give two formats the same bytes and see what each buys. JPEG's compression is built for the smooth gradients of photographs; on hard edges and flat color it rings and smears. A palette PNG stores the chart's few colors exactly. To match it, JPEG needs more than three times the bytes.

On a photograph the result flips: the lossless PNG of the daisy is 710,981 bytes against 11,840 for AVIF (page 01). So the question is which format is best for this kind of image:

  • Drawings, icons, logos, diagrams: SVG first (SVG and Vector Images); if it must be raster, a palette PNG or lossless WebP.
  • Photographs: AVIF, with WebP and JPEG fallbacks in <picture> (Images in HTML). Both newer formats are Baseline, so the JPEG is insurance for older browsers.
  • Screenshots: try lossless WebP and AVIF and compare; the winner depends on the content.
  • Animation: a video, not a GIF (section 3).

3. Optimizing

Once the format is right, optimizing means removing everything the reader would not miss. Five levers, roughly in order of savings:

Resize to the largest displayed size

The biggest waste on the web is still the camera-sized photograph shrunk by CSS. A phone photo is 4,000 pixels wide or more; an article column is perhaps 700 CSS pixels, or 1,400 device pixels on a high-density screen. Everything beyond that is downloaded, decoded and thrown away, and pixel math says the waste grows with the square of the width.

So find the largest size each image is displayed at, multiply by the highest device pixel ratio worth serving (about 2), and resize to that; then make smaller versions and let srcset and sizes choose (Fluid and Responsive Images). This section's lake is encoded at four widths for that reason: the 1,600-pixel JPEG is 119,398 bytes, the 480-pixel one 13,520.

Quality settings

Lossy encoders take a quality setting, usually 0 to 100, and the curve is not straight: near the top each step costs many bytes for an invisible difference, in the middle large savings cost little, and near the bottom the image falls apart. The daisy at five qualities in three formats:

The 800 by 533 daisy, bytes at each quality setting, from the site's build script
QualityJPEGWebPAVIF
105,3836,4343,562
3011,7509,2625,963
5016,79612,27811,840
7023,50615,44020,689
9046,99733,38245,982
Bytes of the daisy photograph at five quality settings in three formats, drawn to scaleGrouped bars for quality 10, 30, 50, 70 and 90. JPEG: 5,383, 11,750, 16,796, 23,506 and 46,997 bytes. WebP: 6,434, 9,262, 12,278, 15,440 and 33,382. AVIF: 3,562, 5,963, 11,840, 20,689 and 45,982. Every format's bytes roughly double from quality 70 to 90.JPEGWebPAVIFbytes, to scale46,997q10q30q50q70q90
The quality ladder, to scale. The step from 70 to 90 roughly doubles every format's bytes; the step from 50 down to 10 saves far less and costs far more in looks.

Two lessons. The top of the scale is expensive: 70 to 90 doubles the JPEG and more than doubles the AVIF. And the scales do not line up: AVIF at 70 is larger than WebP at 70, because AVIF's 70 is a much higher quality. That is why this section's build uses 75 for JPEG and WebP but 50 for AVIF.

Stripping metadata

A photograph from a camera carries metadata: EXIF records of the camera, exposure and date, an orientation flag, sometimes a GPS position, plus color profiles and thumbnails. None of it is drawn, it can add tens of kilobytes, and a GPS position in a photo of someone's home is a privacy leak. Apply the orientation to the pixels first, then strip the rest. Most tools strip when they re-encode; check that yours does.

Palette reduction and lossy PNG

PNG and GIF can store each pixel as an index into a palette of up to 256 colors, one byte or less per pixel. For drawings that loses nothing: chart.png above is a palette PNG. For full-color images, pngquant picks the best 256 colors and dithers the rest: "lossy PNG", often half the bytes, with full alpha transparency, which GIF lacks.

The deck's animated GIF and animated PNG demos make the same point: "normal" and "optimized" look alike and differ greatly in bytes, because optimizers shrink the palette, store only the pixels that change between frames, and drop duplicate frames.

Animation: use a video

Better still is not to use a GIF. A GIF compresses each frame on its own in 256 colors; a video codec predicts each frame from its neighbors and stores only the difference. The same 30-frame pan across the lake:

A 400 by 226, 30-frame animation in four encodings
FileEncodingBytesCompared with the MP4
pan.gifAnimated GIF, 256 colors1,872,830About 73 times larger
pan.webpAnimated WebP213,402About 8 times larger
pan.mp4H.264 video25,587The baseline
pan.webmAV1 video18,978About three quarters
The same animation as GIF, animated WebP, MP4 and WebM, drawn to scaleBars drawn to the same scale. Animated GIF: 1,872,830 bytes, the full width. Animated WebP: 213,402 bytes, about a ninth. H.264 MP4: 25,587 bytes, a sliver. AV1 WebM: 18,978 bytes, a smaller sliver.pan.gif1,872,830 bytespan.webp213,402pan.mp425,587pan.webm18,978
One animation, four files, to scale. The video codecs are barely visible beside the GIF.
<video autoplay muted loop playsinline width="400" height="226" aria-label="A slow pan across a mountain lake."> <source src="pan.webm" type='video/webm; codecs="av01.0.05M.08"'> <source src="pan.mp4" type="video/mp4"> </video>

muted allows autoplay, playsinline stops phones going full screen, and the codecs parameter lets a browser without AV1 fall through to the MP4. Raster Formats covers the formats; Images for People covers readers who ask for reduced motion.

Don't go byte crazy

Optimization has a floor, set by the reader's eyes. The deck shows a JPEG crushed to about 13 KB with its artifacts plain to see. Taking the daisy JPEG from quality 50 to 10 saves 11,413 bytes, about one percent of a median home page's images, and visibly ruins it. Removing one needless image, or sending the right width, saves ten or a hundred times as much.

Stop when the image looks right at its displayed size on your readers' screens. An image that looks bad has failed, however few bytes it took.

4. Tools

Every lever above is a setting in a tool: interactive tools for learning and one-off images, command-line tools for a build that treats every image the same way.

ToolKindGood for
SquooshWeb app (squoosh.app); the codecs run in the browser as WebAssemblyComparing formats and qualities side by side with a split view, before deciding a build's defaults
sharpNode.js library built on the libvips image libraryA build step: resize, rotate, strip, encode JPEG, PNG, WebP, AVIF and GIF, fast
ImageMagickCommand-line suite (magick)Shell scripts and one-off conversions; reads nearly every format there is
pngquantCommand-line toolLossy PNG: palette reduction with full alpha
SVGO and SVGOMGNode.js tool, and a web interface to itRemoving editor clutter from SVG: metadata, hidden layers, needless precision

Start with Squoosh: drop in a photograph, pick a format on each side of the split view, and drag the divider to see artifacts and bytes together. That builds the judgment the build will encode as defaults. SVGOMG does the same for SVG; exports from design tools often halve, because they carry editor metadata and needless decimal places.

A build pipeline: this site's own script

Hand-optimizing does not survive a real project: someone adds a camera original in a hurry and it ships. The durable answer is a build step that owns every variant. Every image file in this section was made by one script, scripts/images/encode.mjs, using sharp:

  • Masters in, variants out. Originals live in images-src/; the script writes every format, width and quality. Nobody edits an output by hand.
  • Defaults in one place. One object holds the quality per format.
  • Orientation applied, metadata gone. sharp's rotate() turns each photo upright from its EXIF flag, and sharp writes no metadata unless asked.
  • A manifest. Every file's bytes and dimensions go into manifest.json, which the demos and tests read, so every byte count on these pages comes from a real file.
// The quality settings a careful default build would use. const GOOD = { jpg: 75, webp: 75, avif: 50 }; function encode(img, ext, q) { switch (ext) { case 'jpg': return img.jpeg({ quality: q ?? GOOD.jpg, mozjpeg: true }); case 'webp': return img.webp({ quality: q ?? GOOD.webp }); case 'avif': return img.avif({ quality: q ?? GOOD.avif }); case 'png-palette': return img.png({ palette: true, compressionLevel: 9 }); // … } } // Each responsive width, in each format, from one master. for (const w of [480, 800, 1200, 1600]) { for (const ext of ['avif', 'webp', 'jpg']) { job(`lake-${w}.${ext}`, 'lake.jpg', () => sharp('images-src/lake.jpg').rotate().resize({ width: w }), ext); } }

mozjpeg: true turns on mozjpeg's tuning: smaller, progressive JPEGs at the same quality. The same jobs in the shell:

# Resize to 800 wide, strip metadata, JPEG at quality 75 magick lake.jpg -auto-orient -resize 800x -strip -quality 75 lake-800.jpg # Lossy PNG: best 256 colors, keep alpha, write logo-fs8.png pngquant --quality=65-85 logo.png # Optimize an SVG exported from a design tool npx svgo icon.svg -o icon.min.svg

5. Fewer requests: sprites and data URIs

Every image is a request, and for most of the web's history each request was expensive: HTTP/1.1 browsers opened about six connections per host, requests waited their turn, and the headers could outweigh a small icon. Two techniques grew up to cut the count: combining images, and putting images inside other files.

Sprites

A sprite sheet is many small images packed into one file; CSS moves a background so only the wanted region shows through each small element. As the deck says, it suits flat-color, lossless formats (GIF, PNG, SVG), not JPEG, whose compression blurs across cell boundaries.

This section's sprite, icons-sprite.png, holds four 48 by 48 icons in one 192 by 48 PNG of 2,322 bytes. The classic code positions it in pixels:

.icon { width: 48px; height: 48px; background: url(icons-sprite.png) no-repeat; } .icon-search { background-position: -48px 0; } .icon-pin { background-position: -96px 0; } .icon-star { background-position: -144px 0; }

That code works at one size only. Sized relative to the element, the sheet scales with the text: stretch it to four times the element's width, and the positions 0%, 33.333%, 66.667% and 100% land exactly on each cell, because a percentage position aligns that fraction of the image with the same fraction of the element:

@layer components { span[data-icon] { --icon-size: 1.5em; display: inline-block; inline-size: var(--icon-size); aspect-ratio: 1; background: url("icons-sprite.png") no-repeat; background-size: 400% 100%; background-position: 0% 0%; &[data-icon="search"] { background-position: 33.333% 0%; } &[data-icon="pin"] { background-position: 66.667% 0%; } &[data-icon="star"] { background-position: 100% 0%; } } }

The modern replacement is an SVG sprite: one file of <symbol> elements, each drawn with <use>. This section's icons.svg holds the same four icons in 1,039 bytes, sharp at every size and stroked in currentColor, so it follows the text color and dark mode (SVG and Vector Images).

<a href="/search/"> <svg aria-hidden="true" focusable="false"><use href="icons.svg#icon-search"/></svg> Search </a>

Why HTTP/2 changed the advice

HTTP/2 sends many requests at once over one connection with compressed headers; HTTP/3 does the same over QUIC, so one lost packet no longer stalls every stream. Both are Baseline. With the per-request overhead mostly gone, a sprite's costs stand out:

  • Caching is all or nothing: change one icon and everyone downloads the whole sheet.
  • Every page pays for every icon, used or not, in bytes and decoded memory.
  • The cells are fixed: one color, one size, one density.

So under HTTP/2 and HTTP/3, raster sprites are mostly unneeded. The deck's wider lesson: technical "rules" are contextual and change with the environment. The SVG sprite survives because its reasons were never about request counts.

Inline images and data URIs

The other way to save a request is to put the image inside a file already on its way. A data URI is a URL that contains its own content: data:, a media type, usually ;base64, a comma, and the bytes as text. The deck's example was a single red dot.

<img src="data:image/webp;base64,UklGRrIAAABXRUJQVlA4IKYAAAAwBQCdASogABUA…" alt="" width="32" height="21">

The costs limit it to small images:

  • Base64 adds about a third: every 3 bytes become 4 characters, so the 186-byte lake-lqip.webp is 248 characters inline.
  • No separate caching: the image is downloaded again whenever the file containing it changes, and ten pages that inline a logo download it ten times.
  • It delays what contains it: bytes inlined in a style sheet hold up rendering.

Use data URIs for a few hundred bytes needed at once, such as a blurred placeholder. For SVG, skip base64 and percent-encode the markup, which is smaller. For anything larger, a separate file wins.

6. The fold, the hero and LCP

Newspapers put the most important story above the fold. On the web, the fold is the bottom of the viewport when the page loads. Every screen puts it somewhere different, but the idea holds: what is above it loads first, and the rest can wait.

Most pages have one large image front and center, the hero. The page does not feel loaded until it appears, so it gets priority over every other image, which in practice means taking priority away from the rest.

Largest Contentful Paint

The standard measure of "the page feels loaded" is Largest Contentful Paint (LCP): the time from the start of navigation until the largest image or text block in the viewport is painted. Candidates include <img> elements, images inside SVG, video posters, CSS background images and block-level text. With a hero, the hero is almost always the LCP element.

LCP is one of Google's Core Web Vitals: 2.5 seconds or less is good, more than 4 seconds poor, measured at the 75th percentile of real page loads, so three readers in four must get a good experience. The LargestContentfulPaint API has been Baseline since December 2025, when Safari shipped it.

The fold, the hero and when each image is requestedOn the left, a tall page. The top part is the viewport, ending at a dashed line labelled the fold; it contains the hero image. Below the fold are three images marked lazy. On the right, a timeline. The HTML arrives first; the hero image request starts almost at once, at high priority, and runs in parallel with the CSS; LCP is marked when the hero paints. The lazy images are requested later, only when the reader scrolls near them.herothe foldlazylazylazyHTMLCSShero image, high priorityLCPlazy images, fetchedas the reader scrollstime
The hero is requested as soon as the HTML names it, and its paint is the LCP. Images below the fold are not requested until the reader scrolls toward them.

An LCP time breaks into four parts, each with its own fix:

PartWhat it isWhat shortens it
Time to first byteUntil the HTML starts arrivingA fast server, a CDN (section 11)
Resource load delayFrom the first byte until the hero's request startsThe hero in the HTML as an <img>, never lazy; fetchpriority="high"; preload if it is discovered late (7, 8)
Resource load durationDownloading the heroFewer bytes: format, size, quality (2, 3); a nearby server; caching
Element render delayFrom download to paintNo render-blocking CSS or JavaScript holding up the paint; no script that inserts the image late

The middle two are where images are won and lost. The browser's preload scanner reads ahead through the HTML for URLs to fetch, but it only finds images written there. A hero set as a CSS background or inserted by a script cannot be requested until the CSS or script has run. Put the hero in the HTML.

Layout shift

The other Web Vital images affect is Cumulative Layout Shift (CLS): a score for how much visible content moves unexpectedly, over the worst burst of shifts. Good is 0.1 or less, poor more than 0.25. An image without width and height has no size until it arrives, then pushes the text down; the fix is dimensions on every content image (Fluid and Responsive Images). Only Chromium implements the Layout Instability API, so CLS field data comes from Chromium users.

7. Loading attributes: loading, fetchpriority, decoding

Three attributes tell the browser how urgent an image is. All are hints about when, and the deck had two of their defaults wrong, so here are the current facts:

AttributeValuesDefaultStatus (October 2026)Use it for
loadingeager, lazyeagerBaseline, widely availablelazy on images below the fold
fetchpriorityhigh, low, autoautoBaseline, newly available since October 2024high on the LCP image; low on images above the fold that matter less
decodingsync, async, autoautoSupported in every engine for yearsRarely needed; see below

loading="lazy"

loading="lazy" makes the browser wait until the image nears the viewport. An image the reader never scrolls to is never downloaded.

<!-- Above the fold: the likely LCP image --> <img src="lake-1200.jpg" alt="…" width="1200" height="800" fetchpriority="high"> <!-- Below the fold --> <img src="cat-800.jpg" alt="…" width="800" height="1067" loading="lazy">

"Near" is up to the browser, and it is further than you might think. Chrome, for example, has started lazy images 1,250 CSS pixels before the viewport on fast connections and 2,500 on slow ones; other engines use their own distances, and pages cannot change them.

The common mistake is a lazy hero. A lazy image cannot be requested until layout shows where it is, which means waiting for the CSS, and the preload scanner cannot start it. It still loads, late, and the LCP pays. Lighthouse's "LCP request discovery" insight flags it.

Lazy loading also needs width and height: without them every image starts with no height, all of them seem to be in the viewport, and all of them load.

fetchpriority

Browsers prioritize requests themselves. Chrome starts images at low priority, raises the first few large ones to medium, and raises those in the viewport to high once layout runs, by which time a hero has already waited behind scripts and style sheets.

fetchpriority="high" says up front what the browser would find out late: this image matters most. low does the opposite, for images above the fold that matter less, such as hidden carousel slides. The default is auto, not low as the deck had it.

Use it sparingly: mark one image high, the LCP element. Five "high" images are no higher than each other. The attribute also works on <link rel="preload">, <script> and fetch().

decoding

After an image arrives it must be decoded into pixels before it can be painted. decoding hints how that relates to painting the rest of the page:

  • sync: present the image together with the surrounding content.
  • async: let the surrounding content paint first.
  • auto: no preference. This is the default, not sync.

The effect is small: browsers decode most images off the main thread, and the hint changes at most whether the image appears in the same frame as its text. When a script swaps images, the element's decode() method, a promise that resolves when the image can paint, is the better tool. Otherwise, leave decoding out.

8. Preloading

Sometimes the browser cannot find the hero early: it is a CSS background, or a script inserts it. <link rel="preload"> in the <head> announces it before the CSS or script does:

<head> <link rel="preload" as="image" fetchpriority="high" href="lake-1200.avif" type="image/avif" imagesrcset="lake-480.avif 480w, lake-800.avif 800w, lake-1200.avif 1200w" imagesizes="100vw"> </head>

as="image" gives the request the right priority and lets it match the later one. imagesrcset and imagesizes mirror the element's srcset and sizes, so the same candidate is preloaded; they are Baseline, widely available since June 2026. type lets a browser without AVIF skip the preload. The same preload can go in an HTTP Link header.

The deck's warning stands: preloading fights the browser, and the browser is often right.

  • It competes with the CSS and fonts the first paint needs. Preload one image at most.
  • Mismatches download twice: if the preload's candidates or format differ from what the element picks, both files are fetched.
  • Unused preloads are waste, and Chrome warns about them in the console. Preloads outlive the designs they were written for.
  • It is unnecessary for an <img> in the HTML: the preload scanner finds it, and fetchpriority="high" is the whole fix.

So the order of preference for a hero is: an <img> in the HTML with fetchpriority="high"; then, only if the image cannot be in the HTML, a preload. Images in CSS covers preloading a background image, where the browser would otherwise find it only after the CSS.

9. Placeholders

An image still loading shows a blank box, or nothing and a jump later. A placeholder fills the box with something cheap that hints at what is coming. The image arrives no sooner, but the wait feels shorter and the layout stays put.

Dominant color

The cheapest placeholder is the image's dominant color as its background. The build computes it with sharp's stats() and records it in the manifest: for the lake, rgb(152 168 200), a misty blue. A dozen bytes of CSS, no request.

Blur-up

A step up is a low-quality image placeholder (LQIP): the photograph shrunk to a tiny size and stretched; the browser's smoothing turns it into a soft blur. This section's is lake-lqip.webp, 32 by 21 pixels, 186 bytes.

A tiny, 32-pixel-wide version of the lake photograph: a blue sky over a dark band of forest.
The whole placeholder at its actual size, 186 bytes. Stretched across the hero, it reads as a blurred version of the photograph. Photograph: Lake Mountain Landscape, Bonnie Moreland, CC0.
@layer components { header img { --placeholder: rgb(152 168 200); background: var(--placeholder) url("lake-lqip.webp") center / cover no-repeat; } }

The real image paints over its own background, so no script is needed; the lazy-loading demo's hero does exactly this. In its log, the placeholder starts after the hero, because a CSS background is found only once the CSS has arrived; inlining it as a data URI (section 5) fixes that.

Since Chrome 112, LCP ignores images with fewer than 0.05 bits per displayed pixel, so a stretched 186-byte placeholder does not count as the LCP and cannot hide a slow hero from the metric.

History: lowsrc

The idea is old: Netscape's lowsrc attribute loaded a small image first and replaced it with the full one. It was never standardized, and HTML lists it as obsolete.

<img src="full.jpg" lowsrc="blur.jpg" width="400" height="300">

Today the CSS background does the job, or a progressive JPEG, which arrives coarse and sharpens (Raster Formats).

10. Caching

"Less often" is the cache's job. A file in the browser's HTTP cache costs no bytes and, if the cache may trust it, no request. Images are large and rarely change, so they cache well. The server sets the terms with Cache-Control:

DirectiveMeaning
max-age=NThe copy is fresh for N seconds: the browser uses it without asking the server
immutableThe file will never change, so the browser should not revalidate even when the reader reloads. Firefox and Safari honor it; Chrome does not support the directive, and since 2017 has avoided revalidating fresh subresources on a normal reload anyway
no-cacheStore the copy, but revalidate with the server before every use
must-revalidateOnce the copy is stale, never use it without revalidating
public / privateWhether shared caches (a CDN, a proxy) may store it, or only the reader's browser

Fingerprinted file names

A long max-age has a catch: change the image and readers keep the old one. So never change a file in place. Give each file a name containing a hash of its content, a fingerprint, such as lake-800.3f9a2c.avif; a new image gets a new name and the HTML points to it. Fingerprinted files can be cached for a year:

HTTP/2 200 content-type: image/avif cache-control: public, max-age=31536000, immutable

The HTML that names them is the opposite: revalidate it every time, so readers learn the new names.

Revalidation and 304

A stale copy need not be downloaded again. The browser sends a conditional request with the copy's ETag (a validator, usually a hash) in If-None-Match; if nothing changed, the server answers 304 Not Modified with no body: a round trip, almost no bytes. The short film 304 walks through one.

This site, asked for one of this section's images:

$ curl -sI https://cse134.site/presentation/images/demos/media/lake-800.jpg HTTP/2 200 content-type: image/jpeg cache-control: public, max-age=0, must-revalidate etag: "a524b12a723ba1e2e6841e98ea203e6f" cf-cache-status: HIT server: cloudflare alt-svc: h3=":443"; ma=86400 $ curl -sI -H 'If-None-Match: "a524b12a723ba1e2e6841e98ea203e6f"' \ https://cse134.site/presentation/images/demos/media/lake-800.jpg HTTP/2 304

The site sends max-age=0, must-revalidate: the browser stores the image but checks before each use and gets a 304 if nothing changed. That cautious setting is right here, because these names are not fingerprinted, and a long max-age would strand readers with an old file after a re-encode. The cost is a round trip per image per visit, which fingerprinting would remove. cf-cache-status: HIT means a nearby edge answered, not the origin; alt-svc advertises HTTP/3.

11. CDNs, image CDNs and content negotiation

"From nearby" is about distance. Light in fiber covers about 200 kilometers per millisecond, and every request is a round trip, so a reader in Sydney fetching from Virginia waits on physics. A content delivery network (CDN) keeps copies on edge servers in cities worldwide and answers from the nearest; only a miss goes back to the origin.

The path of an image request through the browser cache, a CDN edge and the originThree boxes from left to right: the browser with its HTTP cache, a nearby CDN edge, and the origin server. Between the browser and the edge, a request carrying If-None-Match goes right and a 304 with no body comes back. Between the edge and the origin, a request goes right only on a miss, and a 200 with the body comes back. Notes below: a fresh copy needs no request; a stale copy is revalidated, and a 304 sends headers only; the edge answers from nearby, and only a miss goes to the origin.BrowserHTTP cacheCDN edgea nearby copyOriginthe source fileIf-None-Match304, no bodyonly on a miss200 + body1. Fresh (within max-age): the browser uses its own copy and sends no request.2. Stale: the browser revalidates with If-None-Match; a 304 sends headers only.3. The edge answers from nearby; only a miss travels on to the origin.
Less often, from nearby: the browser cache saves the request, revalidation saves the bytes, and the edge saves the distance.

An image CDN also changes the image at the edge: the URL asks for a width, a quality or "best format", and the service resizes and encodes on first request, then caches the result. Cloudinary, imgix and Cloudflare Images are examples. It moves the build of section 4 to the server, which suits sites full of user uploads, at the cost of a vendor's syntax and prices.

Content negotiation with Accept and Vary

"Best format" works through content negotiation: one URL, different content depending on request headers. Browsers list the formats they accept in the Accept header, so the server can send AVIF to those that list it:

GET /img/lake.jpg?w=800 HTTP/2 accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8 HTTP/2 200 content-type: image/avif vary: Accept cache-control: public, max-age=31536000

Vary: Accept is essential: it tells every cache on the way that the response depends on Accept, so a CDN never hands a stored AVIF to a browser that cannot decode it. Accept-Encoding and Accept-Language negotiate compression and language the same way.

The markup gets shorter, but the logic moves to a server or vendor. <picture> is the vendor-free alternative: the browser picks by each <source>'s type, every file is a plain static file, and any host can serve it. Negotiation earns its complexity when images are many, change often, or come from users.

Client Hints

Client Hints are request headers such as Sec-CH-DPR and Sec-CH-Width that describe the device once the server asks with Accept-CH. Only Chromium browsers implement them, and each one used needs Vary. Treat them as an extra on top of markup that already works; srcset and sizes work everywhere.

12. Measuring

Every claim on this page can be checked on your own pages, and should be. Four tools cover most of it.

DevTools: the Network panel

Open DevTools, choose Network, and reload. Then:

  1. Filter to Img, so the status bar counts only images.
  2. Read transferred and resource size: transferred is what crossed the network, with headers; resources is the files' own size. "(memory cache)" or "(disk cache)" means nothing crossed.
  3. Disable cache to make each reload a first visit, then measure the repeat visit too.
  4. Throttle with a preset such as Fast 4G or Slow 4G; campus Wi-Fi flatters every page.
  5. Add the Priority column (right-click the headers): the hero should be High, and lazy images absent until you scroll.

An image request's Headers tab shows its Cache-Control and ETag; its Timing tab shows how long it waited, which is where a late hero gives itself away. The Rendering tab emulates dark mode, reduced motion and forced colors, to check <picture> sources and motion fallbacks.

Lighthouse and the Performance panel

Lighthouse, in Chrome's DevTools, loads the page under simulated mobile conditions. Since Lighthouse 13 (October 2025) its image audits are "insights" shared with the Performance panel:

  • Improve image delivery: better format, quality or size, or video instead of a GIF.
  • LCP request discovery: whether the LCP image was in the HTML, not lazy, and high priority.
  • LCP breakdown: the four parts from section 6.
  • Layout shift culprits: what moved, including images without dimensions.
  • Use efficient cache lifetimes: files with short cache lifetimes.

The old "defer offscreen images" audit was removed, since browsers already deprioritize offscreen images. Lighthouse is one lab load on one simulated device: a checklist, not a verdict.

LCP and CLS from real readers

The thresholds are about real readers at the 75th percentile, which only field data measures. A page can observe its own LCP:

new PerformanceObserver((list) => { const last = list.getEntries().at(-1); // the latest, largest candidate so far console.log('LCP', Math.round(last.startTime), 'ms', last.element); }).observe({ type: 'largest-contentful-paint', buffered: true });

The browser reports a new entry each time a larger element paints; the last before the reader interacts is the LCP. Real monitoring sends it to a server and studies the distribution.

WebPageTest's filmstrip

WebPageTest (webpagetest.org) loads the page in a real browser from a chosen place and connection and records a filmstrip, a frame every tenth of a second. You see the frame where the hero appears, whether a placeholder filled the gap, and whether text jumped, and its waterfall shows a late hero plainly.

13. An image budget

Optimization done once decays: the next feature adds a carousel, the next campaign a 2 MB banner. A performance budget sets limits in advance and checks them on every change. For images, three lines:

  • Total image bytes on first load of a typical page, on mobile;
  • The LCP image's bytes, since it decides the metric;
  • The largest single file, which catches the camera original that slipped through.

A worked example

Take an article page on a phone 400 CSS pixels wide at DPR 2: a hero, six cat photographs in a two-column grid below the fold, a chart, the logo and an icon set. Budget: 200,000 bytes of images, 40,000 for the LCP image, no file over 150,000. The hero needs 800 device pixels; the grid cells about 380, so the 400-pixel files fit.

The article page in a browser that takes AVIF
FileBytes eachCountBytes
lake-800.avif21,823one (the hero)21,823
cat-400.avif13,644six81,864
chart.avif4,727one4,727
icons.svg1,039one1,039
logo.svg900one900
Total110,353

The modern path uses just over half the budget. A browser falling back to JPEG gets lake-800.jpg (33,646), six cat-400.jpg (135,702) and chart.png (6,194) plus the SVGs: 177,481 bytes, still inside, and far below the median mobile home page's 911 KB. With the grid lazy, a reader who stays at the top downloads about 25 KB of images.

Now break it: someone swaps in lake-1600.jpg "to look sharp", 119,398 bytes, three times the LCP limit, for pixels a phone cannot show. The budget catches it; the fix is to put the large file in the srcset, where only large screens choose it.

Enforce the budget where changes happen, in the build or tests. This section's own test fails if a media file passes 400 KB (two labelled bad examples excepted) or the folder passes 6 MB, and checks every byte count on these pages against the manifest.

14. Summary

ConceptKey points
The golden ruleCorrect = fast + pleasing. Load less data, less often, when needed, from nearby.
FormatThe largest single decision. SVG for drawings, a palette PNG if it must be raster, AVIF with WebP and JPEG fallbacks for photographs. Compare formats at equal bytes or equal looks, never by the quality number.
OptimizingResize to the largest displayed size, choose a quality from the middle of the scale, strip metadata after applying orientation, reduce palettes, use video instead of animated GIFs. Stop when it looks right: don't go byte crazy.
ToolsSquoosh and SVGOMG to learn and decide; sharp, ImageMagick, pngquant and SVGO in a build that owns every variant and records what it made.
Fewer requestsRaster sprites are mostly unneeded under HTTP/2 and HTTP/3; SVG <symbol> sprites remain useful. Data URIs add about a third and cannot be cached on their own: small images only.
The fold and LCPThe hero is usually the LCP element. Good LCP is 2.5 s or less at the 75th percentile; good CLS is 0.1 or less. Put the hero in the HTML; give every image dimensions.
Loading attributesloading="lazy" below the fold, never on the hero. fetchpriority="high" on the one LCP image; the default is auto. decoding defaults to auto and rarely matters.
PreloadOnly for a hero the browser cannot discover early, with as="image", imagesrcset and imagesizes matching the element. It competes, and mismatches download twice.
PlaceholdersDominant color for free, a tiny blurred LQIP for a few hundred bytes, painted as the image's background. lowsrc is history.
CachingFingerprinted names with a year's max-age and immutable; otherwise revalidate with ETag and 304, as this site does.
CDNs and negotiationEdges serve from nearby; image CDNs resize and convert there. Negotiation by Accept needs Vary: Accept; <picture> is the vendor-free alternative. Client Hints are Chromium-only.
MeasuringDevTools Network (Img filter, transferred vs. resource size, disable cache, throttling, priority), Lighthouse insights, field LCP and CLS, WebPageTest's filmstrip.
A budgetLimits on total image bytes, the LCP image and the largest file, checked automatically on every change.