Performant Placeholders

Why use placeholder images? How can you load them quickly? How do they affect Largest Contentful Paint? An analysis.


When you let the browser know the width and height of an image before it’s loaded, that space in the layout will be reserved. That helps you Cumulative Layout Shift (CLS). But that space is just blank – unless you fill it visually.

Image placeholders on the web should be a bit more like those dashed blocks in Super Mario World (SNES). They let you know something good is coming.

A block placeholder and a bonus box from Super Mario World, circa 1990
A block placeholder and a bonus box from Super Mario World, circa 1990

Visual placeholders are especially useful for above-the-fold hero images, e.g. product images on product pages or large banners.

tldr;

Placeholder loading strategies and techniques

There are three basic strategies to loading placeholders:

In this post, I’ll take a closer look at the different techniques to implement the “Mini Me” strategy:

Inline base64-encoded placeholders in image tags

I read about this technique in Malte Ubl’s rockin’ post on optimizing image loading for the web in 2021, but had to play around with it to make it work.

Here’s how to load base64-encoded images as placeholders:

  1. Scale down the full size image to a thumbnail of about 40x40 pixels and save it as a JPEG.
  2. Base64-encode the thumbnail (I just manually encoded using aim online encoder, but you should automate this step if you’re working with many images).
  3. Add the base64 code as a the main images’ background-image like so:
<!-- INSERT THE BASE64-ENCODED THUMBNAIL WHERE 'FOOBAR' IS -->
<img
width="250"
height="250"
style
="
background-position:center;
background-repeat:no-repeat;
background-size:100%;
background-image:url(data:image/jpeg;base64,FOOBAR)"

src="https://somedomain.com/images/image_file.jpeg"
/>
  1. Inline a CSS animation for the image so that it appears to blur into sight:
<head>
...
<style>
@keyframes focus-in {
0% {
filter: blur(50px);
transform: scale(0.8);
}
50% {
filter: blur(40px);
}
100% {
filter: blur(0);
transform: scale(1);
}
}

img {
animation: focus-in 2s forwards ease;
}
</style>
</head>
<body>
<img ... />
</body>

Example:

A base64-encoded inline image placeholder. If you open up developer tools, you'll see the LCP candidates logged to console. Try throttling your connection to see how the loading behavior changes.

Upsides

Downsides

Inline base64-encoded placeholders in picture elements

You can also base64-encode a thumbnail to use as a placeholder in picture elements:

<!-- INSERT THE BASE64-ENCODED THUMBNAIL WHERE 'FOOBAR' IS -->
<picture>
<source srcset="https://somedomain.com/images/image_file.webp" type="image/webp" />
<img
width="250"
height="250"
style
="
background-position:center;
background-repeat:no-repeat;
background-size:100%;
background-image:url(data:image/jpeg;base64,FOOBAR)"

src="https://somedomain.com/images/image_file.jpeg"
/>

</picture>

Upsides

Downsides

Loading placeholders as linked resources

I did try loading the thumbnail as an external resource. The image element will remain blank until the external resource is loaded.

A waterfall diagram for loading resources shows: an inline resource loads much sooner than an externally loaded one
An inline resource will render much sooner than an externally loaded one.

That’s not a problem if you’re on a fast connection. But if you’re on 3G, the difference is noticeable:

Inline placeholders render much faster than externally loaded ones on slow 3G
On slow 3G, inline placeholders help with perceived performance more than externally loaded ones. Code on GitHub.

In the example above on an emulated slow 3G connection, the images with inline placeholders (Example A and Example B) both can be perceived to load faster than an image with an externally loaded placeholder. Example B completes the fastest after the smaller WebP file loads.

You could perhaps eke out a better time-to-render by preloading the responsive image and the thumbnail placeholder – especially if you have render-blocking resources in the <head>:

<head>
<!-- Render-blocking resources --->
...
<link rel="preload" as="image" href="placeholder.jpg" />
<link rel="preload" as="image" href="hero.jpg" imagesrcset="hero_400.jpg 400w, hero_800.jpg 800w, hero_1600.jpg 1600w" imagesizes="100vw" />
...
</head>

Upsides

Downsides

How image placeholders affect LCP

I logged LCP candidates using this handy snippet:

<script>
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
console.log('LCP candidate:', entry.startTime, entry);
}
}).observe({ type: 'largest-contentful-paint', buffered: true });
</script>

It gave me some interesting – and some very odd – findings:

Inline images and FCP are friends

Using an inline base64-encoded placeholder as a background image, First Contentful Paint (FCP) was registered once the placeholder was rendered. If there’s nothing else on the page, the FCP is also going to be an LCP candidate (i.e. a preliminary value). Then once the image source has loaded and rendered, it will be the final LCP candidate (i.e. the final LCP value).

Diagram showing performance metrics for an image with an inline placeholder
In isolation, FCP is when the inline placeholder has rendered

When you load image placeholders as background images, they may register as LCP candidates. But once loaded, the image source (or picture source) will register as LCP.

CSS animations don’t hurt LCP

CSS animations that don’t affect an image painting within the viewport (e.g. blur(), scale()) also don’t worsen LCP.

Timeline with LCP proceeding the last animation frame
Even with an animation duration of several seconds, the loading timeline shows that LCP is registered before the final animation frame.

And CSS animations that affect an image painting within the viewport (e.g. flying an image in from outside of the viewport with translate()) may result in the image not even registering as an LCP candidate.

This all makes sense, as web.dev notes:

To keep the performance overhead of calculating and dispatching new performance entries low, changes to an element's size or position do not generate new LCP candidates. Only the element's initial size and position in the viewport is considered. This means images that are initially rendered off-screen and then transition on-screen may not be reported.

Smaller file == LCP?

One thing got me 🤔: even if two images within the viewport have the same physical dimensions and one of the two is intrinsically smaller, that one will register as LCP.

Loading comparison in Chrome DevTools
WTF?! Two images with the same dimensions. The smaller file is Largest Contentful Paint.

I’ll have to do some more research, experiments, and ask around about that one!

Should you use inline placeholders?

In the end, I think the benefits of using placeholder images to improve the perceived performance of important above-the-fold images outweigh the costs. And when using CSS animation, you can get some nice looking transitions.

But I wouldn’t recommend using placeholders for below-the-fold images that are lazy loaded (although I, ahem, currently do so on this site 😉).


Published: Mar 12, 2021

More from my blog

All Blog Articles →