Sep 14, 2022
How I lazy load pixel perfect images on every device
Look, responsive images are hard. Have a go at writing the src-set-sizes syntax from memory, I’ll wait. Where did you set your breakpoints? Why there? Did you remember to account for pixel density? See? Hard. Hopefully you have found a way to automate the process. No? Thoughts and prayers.
The essence of our goal isn’t really that complicated, though. We have a box that we want to fill with an image. An image with a resolution fit for the box being filled. Small box? Small image. Large box? Large image.
However, the browser doesn’t know the size of the box when the image request is sent. It’s so eager to start the download that it doesn’t wait for the layout to complete. What's worse - the image we end up requesting may itself influence the size of the box! In many cases the box doesn’t even have a size without the image!
The official solution (src-set-sizes) requires telling the browser what size the box will be given a certain viewport size. Not at every possible viewport size, of course, but a handful at least. And for each of them we hand the browser a link to a fitting image. Tedious work. Oh, and don’t forget to actually create the image variations. Make sure they’re properly compressed too. Did you adjust the layout and thus change the size of the image? Yeah, you should probably go through the whole process again.
<img alt="..."
src="my-image-400.jpg"
srcset="my-image-400.jpg 400w,
my-image-600.jpg 600w,
my-image-800.jpg 800w,
my-image-1000.jpg 1000w"
sizes="(min-width: 1080px) 760px,
(min-width: 860px) 550px,
(min-width: 550px) 460px,
100vw">
Using src-set-sizes we could conceivably create so many images and breakpoints that close to every device would get a pixel perfect image. That is, an image the exact size needed - accounting for device pixel ratio (DPR) - to fill the box at any conceivable viewport size. Every pixel shipped would get a physical pixel at the other end. One to one.
While this could be automated - and I’m sure it has been - it requires a hefty amount of work up front. Work we’d need to redo every time the layout changed even slightly. And shuffling all those images around would get old real quick.
The first improvement to this hellish scenario is to use an image CDN. Examples include Cloudinary, Imagekit and Cloudflare Images to name a few. With an image CDN we can specify the size of the image we need as a query parameter and have the service generate it for us when the request hits their server. Subsequent requests for the same image at the same size is served from cache. Instead of having to create all the image variations upfront, we simply add fitting query parameters to the URLs in the src-set-sizes markup and upload the high quality originals to the image CDN. Oh, and the image CDN will take care of delivering the most optimal format too.
Unfortunately, the fancy image CDNs don't write the markup for us, so we'd still have to figure out what sizes to actually specify. Can’t we just let the users do it themselves?
The much better way*
I’m a pixel-pincher. You know, like a penny-pincher but with pixels? Anyway, I hate sending along pixels that don’t end up on the screen. It’s wasteful and I don’t like it!
So, let's instead be mindful and have the user request images of the exact size needed for their specific viewport. Here’s how it plays out with a little bit of Javascript:
STEP 1
Each image we wish to load this way gets a special class - I call it lazyfit - and the image-URL is put in the data-src attribute while the regular src attribute is left empty. Make sure the image-element has its final size even when empty. Note how the URL in data-src contains a placeholder for width that will later be replaced. A placeholder for height can be added too.
<img class="lazyfit"
src=""
data-src="https://ik.imagekit.io/johndoe/awesome_image.jpg?tr=w-{width}"
alt="...">
STEP 2
The script grabs all elements with the lazyfit-class and registers them with an IntersectionObserver. This way we can implement lazy loading while we’re at it.
const images = [...document.getElementsByClassName("lazyfit")];
const observer = new IntersectionObserver(...);
for (const image of images) {
observer.observe(image);
}
STEP 3
On intersection, the script pulls the height and width of the empty image-element and uses those numbers to request an image of that exact size from the image CDN using the URL provided in the data-src attribute. The script also accounts for the DPR.
const images = [...document.getElementsByClassName("lazyfit")];
const observer = new IntersectionObserver((entries, observer) => {
for (const entry of entries) {
if (entry.isIntersecting) {
observer.unobserve(entry.target);
const DPR = window.devicePixelRatio;
entry.target.src = entry.target.dataset.src
.replace("{width}", entry.target.width * DPR)
.replace("{height}", entry.target.height * DPR);
}
}
});
for (const image of images) {
observer.observe(image);
}
That’s it! You got pixel perfect images to satisfy your inner pixel-pincher! *Fireworks*
The fine print
But wait a minute! That’s potentially thousands of image-variations! What about the cost of making all those? And the storage?!
Alright, alright. I didn’t say it would be cheap. Let’s see if we can bring the cost down a little.
First of all, there is a finite amount of unique devices out there. That equates to a finite set of screen resolutions. Regular users don't drag around their browser windows all day, you know. Looking at the stats, 20 resolutions cover about 70% of the total number of users worldwide. If you serve a specific market or region, the spread you see is likely even tighter.
Given high resolution originals, the total storage requirement for the 20 variations is probably going to match it. Whether it makes the most sense to store the rest or generate them on demand will depend on the costs of your operation.
Secondly, don’t use the cache of the image CDN - it’s too expensive. Put in a redirect or proxy to cache at your own layer before the request reaches them. At Netlify it’s a redirect rule, at Cloudflare it’s a worker. I’m sure your provider has something similar.
Netlify redirect:
[[redirects]]
from = "/images/*"
to = ":splat"
status = 200
force = true
Cloudflare worker:
export async function onRequest(context) {
const { request } = context;
const regex = /.*images\/(.*)/;
const url = request.url.match(regex)[1];
const imageRequest = new Request(url, {
headers: request.headers
});
return await fetch(imageRequest);
}
Make sure at least the Accept header gets passed along. Without it the image CDN can't deliver the optimal format.
With this way of handling images, my job is to upload the original image to the image CDN, take the returned URL and add it to an image-element with the lazyfit-class.
Making the script a little smarter
There are a few things we can do to make the script a little smarter.
For the IntersectionObserver, I like to grab the viewport height to base the rootMargin on. It’s a fairly straightforward heuristic that works well.
const halfWindowHeight = window.innerHeight / 2;
const images = [...document.getElementsByClassName("lazyfit")];
const observer = new IntersectionObserver((entries, observer) => {...}, {
rootMargin: `${halfWindowHeight}px 0px ${halfWindowHeight}px 0px`
});
In rare cases in some (unnamed) browsers, the sane behavior of empty image-elements can’t be counted on and it can be necessary to base the size of the requested image on the parent-element. I’ve added this option using data-attributes.
<img class="lazyfit"
data-parent="true"
src=""
data-src="..."
alt="...">
const targetParent = entry.target.dataset.parent;
const height = targetParent ? entry.target.parentElement.height : entry.target.height;
const width = targetParent ? entry.target.parentElement.width : entry.target.width;
To extend styling choices, data-attributes are also used to instruct the script to add, remove and toggle classes on image-load. I use this to add a fade-in transition.
<img class="lazyfit"
data-add-class="lazyfit--show"
src=""
data-src="..."
alt="...">
entry.target.addEventListener("load", () => {
if (entry.target.dataset.addClass) {
for (const className of entry.target.dataset.addClass.split(" ")) {
entry.target.classList.add(className);
}
}
if (entry.target.dataset.removeClass) {
for (const className of entry.target.dataset.removeClass.split(" ")) {
entry.target.classList.remove(className);
}
}
if (entry.target.dataset.toggleClass) {
for (const className of entry.target.dataset.toggleClass.split(" ")) {
entry.target.classList.toggle(className);
}
}
});
Today’s high end smartphones have high DPRs - the iPhone 14 is sitting at 3, the Galaxy S22 Ultra at 4 - and loading a full screen, pixel perfect image can require significant bandwidth. Doing so on the S22 Ultra will result in the request of a 1440 by 3088 pixel behemoth. And you know what kind of device folks tend to carry with them to places with bad reception? Smartphones! Shocking, I know! For this reason, the script includes an option to limit the DPR on a per-image basis.
<img class="lazyfit"
data-max-dpr="2"
src=""
data-src="..."
alt="...">
const maxDPR = entry.target.dataset.maxDpr;
const DPR = maxDPR ? Math.min(maxDPR, window.devicePixelRatio) : window.devicePixelRatio;
And finally, I’ve found some browsers' image rendering to be a bit lacking when it comes to half-pixel-cases. That is, cases where the image is some non-integer number of pixels wide or tall. This appears to be especially troublesome with crisp graphics. Using this option, the script will force the image to take on a rounded size. Note that this also makes it impossible for the image to resize e.g. on a switch between portrait and landscape, so use with caution.
<img class="lazyfit"
data-round="true"
src=""
data-src="..."
alt="...">
if (entry.target.dataset.round) {
entry.target.style.height = `${height}px`;
entry.target.style.width = `${width}px`;
}
More caveats
Yes, there’s more!
While src-set-sizes is typically sold as a way to load size-appropriate images, browser-vendors are free to pick and choose between the linked images “depending on the user's screen's pixel density, zoom level, and possibly other factors such as the user's network condition”. To what extent this actually happens I don’t know - my google-fu wasn’t up to the task of finding any useful answers. The script could use the Network Information API to at least account for basic network conditions, but support is currently limited to Chromium-based browsers.
Sometimes you may want to not only deliver a size-appropriate image for a given device, but actually pick an entirely different image altogether. This is what we call art direction and comes as a package deal with the picture-element. My script has none of that.
If your largest contentful paint (LCP) is an image, or includes an image, loading it via a script will add a sizable delay. I even insist on making it deferred! On the upside, since the image size is fixed before load, it will not ruin your CLS score. Here the simple solution is to add the image with src-set-sizes instead. Or - and perhaps even better - don’t have the LCP contain an image.
Then there’s SEO. It should work? At least that’s what Google says. No guarantees given. Admittedly, SEO wasn’t the biggest concern when I implemented this.
Ask your accountant about lazyfit
You should probably be weary about implementing this on anything bigger than a side-project or personal portfolio site. I haven’t yet had the opportunity to implement it on a site with traffic of note and I’m pretty sure there’ll be important stuff to discover in the process. There’s not much fun in getting a surprise bill from Cloudinary!
Lazyfit in full:
const halfWindowHeight = window.innerHeight / 2;
const images = [...document.getElementsByClassName("lazyfit")];
const observer = new IntersectionObserver((entries, observer) => {
for (const entry of entries) {
if (entry.isIntersecting) {
observer.unobserve(entry.target);
const targetParent = entry.target.dataset.parent;
const height = targetParent ? entry.target.parentElement.height : entry.target.height;
const width = targetParent ? entry.target.parentElement.width : entry.target.width;
if (entry.target.dataset.round) {
entry.target.style.height = `${height}px`;
entry.target.style.width = `${width}px`;
}
const maxDPR = entry.target.dataset.maxDpr;
const DPR = maxDPR ? Math.min(maxDPR, window.devicePixelRatio) : window.devicePixelRatio;
entry.target.src = entry.target.dataset.src.replace("{width}", width * DPR).replace("{height}", height * DPR);
entry.target.addEventListener("load", () => {
if (entry.target.dataset.addClass) {
for (const className of entry.target.dataset.addClass.split(" ")) {
entry.target.classList.add(className);
}
}
if (entry.target.dataset.removeClass) {
for (const className of entry.target.dataset.removeClass.split(" ")) {
entry.target.classList.remove(className);
}
}
if (entry.target.dataset.toggleClass) {
for (const className of entry.target.dataset.toggleClass.split(" ")) {
entry.target.classList.toggle(className);
}
}
});
}
}
}, {
rootMargin: `${halfWindowHeight}px 0px ${halfWindowHeight}px 0px`
});
for (const image of images) {
observer.observe(image);
}
Article illustrations generated by Stable Diffusion