Serving sharp images to high density screens

A long time ago we had monitors of varying resolutions, but once we started to go beyond 1024x768, screens started to get bigger as resolution got bigger.

Then full-colour web-capable mobile phones arrived, but the story was the same. They had small screens, but also small resolutions.

Then in 2010 the iPhone 4 came out (holy shit that's 11 years ago), and the screen was 640×960, whereas the similar-sized iPhone 3 was 320×480. The resolution doubled, while the screen size was roughly the same, so in effect the screen density doubled.

Apple couldn't just run the same apps but at a higher resolution, because things like text and buttons would be tiny, so they doubled the size of everything. 1 pixel became 2 'device pixels'.

On the web we call this the 'device pixel ratio', or DPR. The iPhone 4 had a DPR of 2. This is sometimes referred to as having a 2x screen, or 2dppx.

Ok, history lesson over…

Matt Hobbs from gov.uk was kind enough to share April 2021 stats on users' device pixel ratio. I ran a few queries on it, and it turns out 80% of their users are on a screen with a DPR of 1.5 or above. That's:

  • …over 99.9% of their mobile users.
  • …32% of their desktop users.
  • …78% of their tablet users.

Of course, gov.uk is UK-centric, but it covers a broad section of the UK population. Not just tech users, not just rich users. I think we can safely draw the following conclusions:

  • Users at smaller viewports will likely be using high density screens, unless the site expects a large amount of feature phone traffic.
  • Users at larger viewports are less likely to be using high density screens, but it's a growing number, unless the site expects traffic from mostly low-end devices.

In many cases, users with high-density screens could be the majority of your traffic.

Why cater for high density screens?

The rest of the display will be rendered at full density, including things like text, SVG, browser UI… so if an image is rendered at lower density, it can look blurry or low quality:

The effect can be subtle depending on the density of your screen, but you should see a sharpening, particularly around the red panda's whiskers and ear fur.

So, if you want your images to be as sharp as possible, you need to target images at the user's device pixels, rather than their CSS pixels.

Compressing images for high density screens

Here's a 1x version of the red panda image, 400px wide:

I encoded this using WebP at quality 80. Any lower than that and significant details were lost. It comes in at 14.9 kB. Not bad!

If I encode the 2x version at the same settings, it's 45.2 kB, which roughly makes sense as it's 4 times the number of pixels. But does it need to use the same settings?

To my eyes the 2x version at 21.2 kB is good enough. It's not exactly the same, but it doesn't look ugly. Here it is doubled in size, so it's 1x with CSS pixels:

Now it looks ugly. You can easily see the compression artefacts compared to the higher quality version. But when it's zoomed out, it's fine.

Human eyes are weird. They're good enough to benefit from a high density image, but not good enough to see compression artefacts as clearly at that density, particularly in 'high frequency' areas of an image, where the brightness changes a lot from pixel to pixel.

To encode a 2x image, I throw it into Squoosh.app, and zoom it out until it's the size it'll be displayed on a page. Then I just drag the quality slider as low as it'll go before it starts looking bad.

So, we can go from a 1x image at 14.9 kB to a 2x image at 21.2 kB, gaining loads of sharpness without massively increasing the file size.

For even higher density screens you can drop the quality further, but the drop isn't as big as 1x to 2x, so it isn't always worth catering for.

Ok, that's the theory, but how do you actually make this work on a page?

Catering for 1x and 2x qualities isn't straight forward

Here's a basic responsive image:

<img
  srcset="
    image-700.jpg   700w,
    image-1000.jpg 1000w,
    image-1300.jpg 1300w,
    image-1600.jpg 1600w
  "
  sizes=""
  alt=""
/>

I've got a more detailed post on how responsive images work, but the short story is the srcset tells the browser all the versions of the images that are available, indexed by their pixel width, and sizes tells the browser how big the <img> will appear in CSS pixels.

This means the browser can make choices like "oh, this image is going to display 500 CSS pixels wide, but it's a 2x screen, so I'll download image-1000.jpg". Great!

Except, not so great. Let's say we encode image-1000.jpg as if it were being displayed on a high density mobile device, so we use a lower quality. That'll work great on high density mobile devices, as they'll get a sharp image at a low file size. Unfortunately, the browser may choose the same image for a 1x desktop device, and it'll look bad, like the zoomed-in red panda image above.

This means most sites serve images that are 100% heavier than they need to be in terms of file size, and it's mostly mobile users that take the hit, and they're likely to be on the slowest connection.

So, what's the answer?

The lazy way

Here's the technique I use for most images on this blog: I take the maximum size the image can be displayed in CSS pixels, and I multiply that by two, and I encode it at a lower quality, as it'll always be displayed at a 2x density or greater. Yep. That's it.

For 'large' images in blog posts like this, they're at their biggest when the viewport is 799px wide, where they take up the full viewport width. So I encode the image 1,598 pixels wide.

Because this is so quick and easy, I have time to throw in a few extra formats:

<picture>
  <source type="image/avif" srcset="red-panda.avif" />
  <source type="image/webp" srcset="red-panda.webp" />
  <img src="red-panda.jpg" width="1598" height="1026" alt="A red panda" />
</picture>

Here's a live example:

A red panda

And here are the formats separately:

This method is far from perfect. The WebP is 57.5 kB, but we've already seen that a carefully mobile-optimised WebP is 21.2 kB. That's a significant jump in size. But, a lot of users will get the AVIF, which is 37.5 kB. Although, there's also the decoding cost of handling bigger images, but… but…

Ok, I'm just making excuses for being lazy. But like I said, these are carefully optimised, and most images on the web are not carefully optimised. If you do something like above, you're handling images way better than the majority of sites on the web. If you've been following my series looking at the performance of F1 websites, a lot of images of these dimensions end up at 300kB+.

But let's do it properly…

The 'full' way

We want to serve a different set of images for high-density screens than 1x screens. Thankfully the <picture> and <source> tags let us do this.

<picture>
  <source media="(-webkit-min-device-pixel-ratio: 1.5)" srcset="" sizes="" />
  <img srcset="" sizes="" width="1598" height="1026" alt="A red panda" />
</picture>

Sources can take a media query, and we use (-webkit-min-device-pixel-ratio: 1.5) to target screens that are at least 1.5x. The 'proper' web standards way to do this is (min-resolution: 1.5x), but Safari doesn't support it. But, it's ok to just use -webkit-min-device-pixel-ratio; it was added to the compatibility standard due to the number of sites using it, and now all browsers support it.

Now I just need to figure out the sizes and srcset. Right now, sizes needs to be repeated for the <img> and every <source>, although I'm working on a spec change so it'll only be needed on <img>.

Here are the sizes for 'large' images on this blog:

<picture>
  <source
    media="(-webkit-min-device-pixel-ratio: 1.5)"
    srcset=""
    sizes="
      (min-width: 1066px) 743px,
      (min-width: 800px) calc(75vw - 57px),
      100vw
    "
  />
  <img
    srcset=""
    sizes="
      (min-width: 1066px) 743px,
      (min-width: 800px) calc(75vw - 57px),
      100vw
    "
    width="1598"
    height="1026"
    alt="A red panda"
  />
</picture>

This means, at viewport widths 1066px or greater, the image is fixed at 743px wide. Otherwise, at viewport widths 800px or greater, the image is 75% of the viewport width minus 57px. Otherwise, the image is full viewport width. sizes don't need to be 100% accurate like they are here, but the more accurate they are, the better choice the browser will make.

So, what about the srcset? I can cut some corners here for 1x. The stats suggest that 1x screens are predominantly desktop, which skews towards wider viewports. In this case, I think it's fine to assume the viewport is probably 1066px wide or greater, so I'm going to be a bit lazy again and create one image for 1x users, at 743w:

<picture>
  <source
    media="(-webkit-min-device-pixel-ratio: 1.5)"
    srcset=""
    sizes="
      (min-width: 1066px) 743px,
      (min-width: 800px) calc(75vw - 57px),
      100vw
    "
  />
  <img src="1x-743.jpg" width="743" height="477" alt="A red panda" />
</picture>

Since there's only one src on the <img>, I can remove the sizes there too.

When I compressed the 1x image, I had it zoomed to 100% in Squoosh. As such as I had to drag the quality slider much higher than I would with the 2x images I was compressing earlier.

Ok, what about the 2x images? Mobiles tend to have a viewport width around 320-420px, so I'm going to go with 800w for the lower end. I already decided on the higher end in the previous section: 1598w. I'm never quite sure how many steps in between to do, so I'm going to go with one, 1200w.

<picture>
  <source
    media="(-webkit-min-device-pixel-ratio: 1.5)"
    srcset="2x-800.jpg 800w, 2x-1200.jpg 1200w, 2x-1598.jpg 1598w"
    sizes="
      (min-width: 1066px) 743px,
      (min-width: 800px) calc(75vw - 57px),
      100vw
    "
  />
  <img src="1x-743.jpg" width="743" height="477" alt="A red panda" />
</picture>

I compressed these after zooming them out to the sizes they were likely to be displayed, so I was able to go much lower with the quality slider.

But that's just JPEG! Let's add some more image formats:

<picture>
  <source
    type="image/avif"
    media="(-webkit-min-device-pixel-ratio: 1.5)"
    srcset="2x-800.avif 800w, 2x-1200.avif 1200w, 2x-1598.avif 1598w"
    sizes="
      (min-width: 1066px) 743px,
      (min-width: 800px) calc(75vw - 57px),
      100vw
    "
  />
  <source
    type="image/webp"
    media="(-webkit-min-device-pixel-ratio: 1.5)"
    srcset="2x-800.webp 800w, 2x-1200.webp 1200w, 2x-1598.webp 1598w"
    sizes="
      (min-width: 1066px) 743px,
      (min-width: 800px) calc(75vw - 57px),
      100vw
    "
  />
  <source
    media="(-webkit-min-device-pixel-ratio: 1.5)"
    srcset="2x-800.jpg 800w, 2x-1200.jpg 1200w, 2x-1598.jpg 1598w"
    sizes="
      (min-width: 1066px) 743px,
      (min-width: 800px) calc(75vw - 57px),
      100vw
    "
  />
  <source type="image/avif" srcset="1x-743.avif" />
  <source type="image/webp" srcset="1x-743.webp" />
  <img src="1x-743.jpg" width="743" height="477" alt="A red panda" />
</picture>

Phew! And here's a live example:

A red panda

This technique gets a lot easier if you use an image service or sorts. In fact, The Guardian use a very similar technique, although the 'type' decision is made server side using the browser's Accept header.

And that's it!

Will I switch from the 'lazy' way to the 'proper' way? Probably not for this blog. Part of me really enjoys manually compressing images to newer formats, but I don't think I'm ready to do it 12 times per image. 3 is enough.

However, if I wanted to switch to automated compression, which may not be as good as doing it manually for each image, I think I'd do it the 'proper' way to keep images small, particularly for mobile users.

View this page on GitHub

Comments powered by Disqus

Jake Archibald next to a 90km sign

Hello, I’m Jake and that is my tired face. I’m a developer advocate for Google Chrome.

Elsewhere

Contact

Feel free to throw me an email, unless you're a recruiter, or someone trying to offer me 'sponsored content' for this site, in which case write your request on a piece of paper, and fling it out the window.