Experiments / 02

Images

What does the browser do with a 2.4 MB hero?

  • Beginner
  • 12 min
  • Impact ●●●

The problem

On most pages images are the heaviest thing the browser downloads, and very often the single biggest element on screen, which makes one image the thing your Largest Contentful Paint depends on.

“Optimise your images” hides four separate questions, and each has a different answer:

  1. How many bytes? Format, quality and above all the number of pixels.
  2. The right bytes for this screen? srcset and sizes.
  3. When? Discovery, priority, lazy loading and preloading.
  4. How many requests? Sprite sheets, and why the answer changed.

Each has its own experiment below. They share one idea: measure what the browser is really doing before you change anything.

1 · Format, quality and pixels

Pick a kind of image and a width. The bar chart shows the typical size in each format. The images under it are a real JPEG encode done by your browser.

Kind of image

Continuous tone and fine detail

2400 px
80
File size · 2400 × 1600 px
  • JPEG844 KBLossy. Universal support. The baseline everything else is compared with.
  • PNG4.1 MB+400% vs JPEGLossless. Huge for photographs; quality has no effect.
  • WebP610 KB-28% vs JPEGLossy or lossless. Supported by every current browser.
  • AVIF422 KB-50% vs JPEGSmallest for photos at equal quality. Slower to encode; very broad support now.
  • SVGn/aNot applicable: a photograph is not vector content.

Switching JPEG to AVIF saves 50%. Serving the image at half the width saves 75%. Sending the right number of pixels usually beats changing format.

Lowering quality from 100 to about 80 is where most savings are, and the changes are hard to see. Below about 60 it gets visible. Drag the quality slider and look at the right-hand image.

Quality, for real · zoomed 2×Encoded by your browser, not modelled
Original · uncompressed
Needs JavaScript
JPEG · quality 80

Sizes use typical bits-per-pixel ratios, not real encoders. See the assumptions below.

Choosing a format

JPEG
Lossy, universal, still fine for photos. It’s the baseline to beat, not a mistake to fix.
PNG
Lossless. Excellent for flat colour, screenshots and sharp edges, and very large for photographs. Quality settings don’t apply.
WebP
Usually noticeably smaller than JPEG at similar quality, with lossless and transparency options. Supported by every current browser.
AVIF
Often the smallest for photos. The trade-offs are slower encoding and, on weak devices, more decode work. Broadly supported in current browsers.
SVG
Vector. The right choice for logos, icons and simple illustrations: tiny, sharp at any size, styleable. The wrong choice for photographs.

To offer a newer format with a safe fallback, use <picture>. The browser takes the first source it understands:

<picture>
  <source type="image/avif" srcset="hero.avif">
  <source type="image/webp" srcset="hero.webp">
  <img src="hero.jpg" alt="…" width="1200" height="800">
</picture>

Pixels beat format. Drag the width in the widget. Halving the width removes about three quarters of the pixels, which is usually a bigger saving than any format change, and costs no visible quality if the screen can’t show those pixels anyway.

2 · The right image for this screen

Try a phone, then a laptop. Then switch off sizes, and then srcset, and watch the downloaded size change.

Pixel density
390 px
Downloaded
212 KB
Browser picks
1200 px
Screen needs
1074 px
Wasted
42 KB
The screen390 CSS px wide · 3× = 1170 device px
358 px
Candidates the browser can choose from
  • 400w24 KBtoo small
  • 800w94 KBtoo small
  • 1200w212 KB← chosen
  • 1600w376 KB
  • 2400w844 KB
<img src="hero-2400.jpg"
     srcset="hero-400.jpg 400w,
            hero-800.jpg 800w,
            hero-1200.jpg 1200w,
            hero-1600.jpg 1600w,
            hero-2400.jpg 2400w"
     alt="…" width="2400" height="1600">

With no sizes attribute the browser assumes the image fills the viewport (390px), but it really renders at 358px. It picks the 1200px file instead of a smaller one: 20% of the bytes are wasted.

JPEG at quality 80. Byte sizes are modelled; the candidate-selection rule is how browsers behave.

How the browser chooses

  • srcset lists candidate files with their real pixel widths (800w). It describes the files. It doesn’t say how big the image will be on screen.
  • sizes tells the browser how wide the image will be laid out, in CSS pixels, before it has done layout. The browser needs this to choose a file early. If you leave it out, it assumes 100vw.
  • The browser multiplies that width by the device’s pixel density (2× and 3× screens have more physical pixels per CSS pixel) and picks the smallest candidate that covers it.
  • The rules let a browser deviate. It may prefer a file it has already cached, or a smaller one on a slow connection. The widget shows the straightforward rule.

Use srcset and sizes when the same image should be the same picture at different resolutions. Use <picture> with media conditions for art direction, when small screens need a different crop.

Always give images width and height attributes. Browsers use them to reserve the right space before the file arrives. That’s the next experiment.

3 · When images load: discovery, priority and layout shift

Press Replay and watch the page build. Then try each preset, and toggle techniques one at a time. Look at which part of the LCP bar changes.

How the browser finds the hero
Techniques
350 KB
12
10 Mbps
150 ms
Largest Contentful Paint
2.72 s
Layout shift (CLS)
0.68
Images fetched
2.0 MB
Deferred
0 KB
Where the LCP time goes
  • Document TTFB 650 ms
  • Resource load delay 16 ms
  • Resource load duration 2.02 s
  • Element render delay 40 ms
The page

shift so far: 0.68

Network
  • Not yet discovered
  • Waiting
  • Download
2.72 s

The hero shares bandwidth with 14 other images, most of them below the fold. Lazy-loading them, or marking the hero fetchpriority="high", gives the hero the link.

Without width and height the page reserves no space, so content jumps as each image lands: layout shift 0.68. Reserving the space makes it 0.

One HTTP/2 connection. Simplified browser priorities. CWV thresholds: LCP good ≤ 2.5 s, CLS good ≤ 0.1.

What the LCP breakdown tells you

The time to paint the hero splits into four parts, and each has a different fix:

  • Document TTFB: server and network. Not an image problem.
  • Resource load delay: time between the HTML arriving and the browser starting to fetch the image. If it’s large, the image was found late: it lives in CSS, or JavaScript renders it, or you lazy-loaded it. Fix by putting a real <img> in the HTML or preloading.
  • Resource load duration: the download. Fix with fewer bytes, or with fewer competing downloads (fetchpriority, lazy-loading things below the fold).
  • Element render delay: decode and paint, usually small.

Techniques and their traps

loading="lazy"
Defers an image until it is near the viewport. Excellent for content below the fold. Applied to the hero it delays the most important request. Don’t lazy-load the LCP image.
fetchpriority="high"
Raises the image’s priority in the browser’s request scheduler. Use it sparingly, on the one image that matters. If everything is “high”, nothing is.
rel="preload"
Starts the fetch early. It helps when the browser can’t discover the image by scanning the HTML. When the image is already a plain <img> it adds almost nothing, and a wrong preload can take bandwidth from something more important. For responsive images, use imagesrcset and imagesizes so it preloads the same file the <img> would pick.
width and height
Let the browser compute the aspect ratio and reserve space, so nothing jumps when the image arrives. A score above 0.1 is “needs improvement” and above 0.25 is “poor”.

Common misconceptions

“Lazy loading everything is better.”

Not for what’s on screen. Lazy loading saves bytes by waiting, and waiting is exactly the wrong thing for the hero. Above-the-fold images should load as early as possible.

“Preload makes everything faster.”

It only moves a request earlier. That helps when the image is discovered late. Preload too many things and they compete with each other and with CSS and JavaScript.

“A smaller file is always faster.”

Usually, not always. A more aggressive format can cost more to decode on a slow phone. A smaller file that is discovered late, or sits behind a queue, still arrives late. Bytes are one of several costs.

4 · Sprite sheets: one big file or many small ones?

Start on HTTP/1.1 with 24 icons, then switch to HTTP/2. Then try the 200-icon preset and look at the bytes and the repeat-visit bars.

Protocol
24
0
4 KB
90%
120 ms
25 Mbps
First visit
Sprite by 423 ms
Repeat visit
about the same
Bytes: files / sheet
96 KB / 88 KB
First visit · connection already open from the HTML request

Separate files 24 requests · 591 ms

Sprite sheet 1 request · 169 ms

Load time
  • First visit
    Separate files
    591 ms
  • First visit
    Sprite sheet
    169 ms
  • Repeat visit
    Separate files
    143 ms
  • Repeat visit
    Sprite sheet (expected)
    155 ms
<!-- separate files: 24 requests -->
<img src="icons/home.svg" alt="" width="24" height="24">
<img src="icons/search.svg" alt="" width="24" height="24">
…

<!-- SVG sprite: 1 request, icons referenced by id -->
<svg width="24" height="24"><use href="icons.svg#home"/></svg>
<svg width="24" height="24"><use href="icons.svg#search"/></svg>
…

On HTTP/1.1 the 24 files queue behind each other on six connections, so one combined file saves 423 ms. This is where sprite sheets came from.

Cache: each icon is unchanged with probability 90%, but a sheet of 24 icons is only reusable if every one is unchanged. Chance it must be re-downloaded in full: 92%. Separate files re-fetch only what changed.

Sheet is 8% smaller than the sum of its icons. Repeat visit: sprite time is the expected value.

Was it a mistake, or just history?

Sprite sheets solved a real problem: on HTTP/1.1 each request waits for a connection, so fewer requests meant a faster page. That’s the first-visit bar for HTTP/1.1.

This was historically useful, but modern HTTP changes the trade-off. With multiplexing the first-visit gap shrinks, and the costs stay:

  • Unused bytes. Every page downloads the whole sheet, including icons it never shows.
  • Brittle caching. Change one icon and every visitor re-downloads the lot. The widget’s repeat-visit probability is that effect.
  • Maintenance. Offsets in CSS, and awkward scaling and high-DPI handling.
  • Accessibility. CSS background icons carry no alternative text.

Where a sheet still makes sense: a very large number of tiny images on a slow, high-latency link, or game and canvas textures (an atlas), where the whole set is needed at once. For UI icons the usual answer today is inline SVG or an SVG sprite referenced with <use>.

The correct approach depends on protocol, how many icons a page really uses, and how often they change. Measure before optimising.

See it on a real page

  1. In DevTools open Network, filter to Img, and sort by size. Look for images much larger than the space they fill.
  2. In the Performance panel record a page load and open the LCP insight. It reports the same four phases as the widget.
  3. Or ask the page directly:
    new PerformanceObserver((list) => {
      for (const e of list.getEntries()) console.log(e.startTime, e.url, e.element);
    }).observe({ type: 'largest-contentful-paint', buffered: true });
  4. Lighthouse’s “Properly size images” and “Avoid lazy loading LCP image” audits point at the same problems. This page is the reason behind them.

Model assumptions

What these simulations simplify
  • File sizes come from typical bits-per-pixel ratios by format and content type, not real encoders. Real results vary a lot with the image and settings. The quality preview is a real JPEG encode of a synthetic scene.
  • srcset follows the standard selection rule. Real browsers can also consider caching and network conditions.
  • Page load is one HTTP/2 connection with bandwidth shared by priority weight (4 for CSS, JS and a high-priority hero, 1 for other images). Real priorities are more nuanced. HTML 20 KB, CSS 60 KB, JS 150 KB.
  • Layout shift uses the same impact × distance idea as the browser’s metric, on a fixed 800 px viewport with a simple page.
  • Sprite sheets assume the connection is already open, the sheet is 8% smaller than its parts, and icons change independently.