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.
Visual placeholders are especially useful for above-the-fold hero images, e.g. product images on product pages or large banners.
tldr;
- Inlining image placeholders can help improve perceived performance for critical above-the-fold hero images
- CSS animation doesn’t affect Largest Contentful Paint (LCP)
Placeholder loading strategies and techniques
There are three basic strategies to loading placeholders:
- “Fancy Pants”, i.e. CSS background styling
- “The Ol’ Switcheroo”, i.e. switching out elements via JS
- “Mini Me”, i.e. loading a small thumbnail image as a background image
In this post, I’ll take a closer look at the different techniques to implement the “Mini Me” strategy:
- as a base64-encoded inlined background image in an image tag
- as a base64-encoded inlined background image in an image tag within a picture element
- linking a resource for a background image in an image tag
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:
- Scale down the full size image to a thumbnail of about 40x40 pixels and save it as a JPEG.
- 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).
- 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"
/>
- 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:
Upsides
- The fastest way to provide a visual cue that an image is arriving
Downsides
- Inlined resources cannot be cached
- The main doc will be a couple of KB larger
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
- Immediate rendering without any latency
- You get all the benefits of picture elements (optimal image sizes, formats, art direction, etc.)
Downsides
- Inlined resources cannot be cached
- The main doc will be a couple of KB larger
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.
That’s not a problem if you’re on a fast connection. But if you’re on 3G, the difference is noticeable:
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
- Thumbnail resource can be cached
- If your image server does on-the-fly transformations, you can easily get a URL for the thumbnail
Downsides
- First Paint is noticeably slow on sub-4G connections
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).
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.
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.
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 😉).