Optimal Images in HTML
So you've got your nice page and you're adding your background image and…
.hero {
/* 🚩 */
background-image: url("/image.png");
}
WAIT!
Did you know that this is going to be very unoptimized for performance? And in more ways than one.
Why you should (generally) avoid background-image
in CSS
Optimal image sizing
Outside of using SVGs, there's virtually no case where every visitor to your site should receive the exact same image file, given the vast amount of screen sizes and resolutions individuals have these days.
Does your site even work on watches yet? (Kidding…I think)
You could say, "oh! Media queries, I’ll manually specify a range of sizes of screen sizes and images":
/* 🚩 */
.hero {
background-image: url("/image.png");
}
@media only screen and (min-width: 768px) {
.hero {
background-image: url("/image-768.png");
}
}
@media only screen and (min-width: 1268px) {
.hero {
background-image: url("/image-1268.png");
}
}
Well, there is a problem with this. Besides being quite tedious and verbose, this is only taking screen size into account, but not resolution.
So you could say, "aha! I know a cool trick for this, [image-set](https://developer.mozilla.org/en-US/docs/Web/CSS/image/image-set)
to specify different image sizes for different resolutions":
/* 🚩 */
.hero {
background-image: image-set(url("/image-1x.png") 1x, url("/image-2x.png") 2x);
}
And you’d be right, this has some benefits. But, generally speaking, we need to take into account both screen size and resolution.
So we could write some bloated CSS that combined media queries and image-set, but this is just getting complex, and it means we need to know exactly how large our image is for each screen, even as the site layout evolves over time.
And still, this doesn’t support critical things like lazy loading, next-gen formats for supported browsers, priority hints, async decoding, and more.
And to top things off, we also have an issue with chained requests.
Avoiding chained requests
- X Fetch HTML -> Fetch CSS -> Fetch Image
- O Fetch HTML -> Fetch Image
With an image tag, you have the link to the src
right in the HTML. So the browser can fetch the initial HTML, scan for images, and begin fetching high-priority images immediately.
In the case of loading images in CSS, assuming you use external stylesheets (link rel=”styleshset”
, like most do, instead of inline style
everywhere) the browser must scan your HTML, fetch the CSS, and then find that a background-image
is applied to an element, and only after all of that can go fetch that image. This will take longer.
And yes, you can work around some things, like inlining CSS, preloading images, and pre-connecting to origins. But, as you read on, you will see additional advantages you get with the HTML img
tag that you sadly don’t get with background-image
in CSS.
When to consider a background image
Before we move on to discuss the most optimal way of loading images, we have to remember that like all rules, there are exceptions. For instance, if you have a very small image you want to tile with background-repeat
, there isn’t an easy way to accomplish repeating (that I know of) with img
tags.
But for any image that is larger than, say, 50px, I would highly suggest avoiding setting it in CSS and instead using an img
tag for virtually everything.
Optimally loading images
Now that we’ve complained about the challenges of using background-image
in CSS, let’s talk actual solutions.
In modern HTML, the img
tag gives us a number of useful attributes to optimally load images. Let’s run through them.
Browser-native lazy loading
The first amazing attribute we get on an img
tag to improve our image performance is loading=lazy
:
<!-- 😍 -->
<img loading="lazy" ... />
This is already a huge improvement, as now your visitors won’t automatically download images that are not even in the viewport. And even better — this has great performance, it’s fully natively implemented by browsers, requires no JS, and is supported by all modern browsers
Note one important detail — ideally, do not lazy load images “above the fold” (that is, images that will be in the browser’s viewport immediately on first load). That will help ensure your most critical images load as soon as possible, and all others will load only as needed.
P.S.: loading=lazy
also works on [iframes](https://web.dev/iframe-lazy-loading/)
😍
Optimal sizing for all screen sizes and resolutions
Using srcset
with your images is critical. Unless you are loading an SVG, you need to make sure that different screen sizes and resolutions get an optimally sized image:
<img
srcset="
/image.png?width=100 100w,
/image.png?width=200 200w,
/image.png?width=400 400w,
/image.png?width=800 800w
"
...
/>
One important thing to note is that this is a more powerful version than you get with image-set
in CSS, because you can use the [w](https://html.spec.whatwg.org/multipage/images.html#introduction-3:viewport-based-selection-2)
unit in an img
srcset
.
What is useful about it is that it takes both size and resolution into account. So, if the image is currently displaying 200px wide, on a 2x pixel density device, with the above srcset
the browser will know to grab the 400w
image (that is, the image that is 400px
wide, so it displays perfectly at 2x pixel density). Similarly, the same image on a 1x pixel density image will grab the 200w
image.
Modern formats with the picture
tag
You may have noticed we’re using a .png
in our examples here. This is supported by any browser, but is almost never the most optimal image format.
This is where adding the picture
around our img
can allow us to specify more modern and optimal formats, such as webp, and supported browsers will favor those, via the source
tag:
<picture>
<source
type="image/webp"
srcset="
/image.webp?width=100 100w,
/image.webp?width=200 200w,
/image.webp?width=400 400w,
/image.webp?width=800 800w
"
/>
<img ... />
</picture>
Optionally, you can support additional formats as well, such as AVIF:
<picture>
<source
type="image/avif"
srcset="
/image.avif?width=100 100w,
/image.avif?width=200 200w,
/image.avif?width=400 400w,
/image.avif?width=800 800w,
...
"
/>
<source
type="image/webp"
srcset="
/image.webp?width=100 100w,
/image.webp?width=200 200w,
/image.webp?width=400 400w,
/image.webp?width=800 800w,
...
"
/>
<img ... />
</picture>
Don’t forget the aspect-ratio
It’s important to keep in mind that we also want to avoid layout shifts. This happens when an image loads if you don’t specify a precise size for the image ahead of the image downloading. There are two ways you can accomplish this.
The first is to specify a width
and height
attribute for your image. And optionally, but often a good idea, set the images height
to auto
in CSS so that the image is properly responsive as the screen size changes:
<img width="500" height="300" style="height: auto" ... />
Alternatively, you can also just use the newer aspect-ratio
property in CSS to always have the right aspect ratio automatically. With this option, you don’t need to know the exact width and height of your image, just its aspect ratio:
<img style="aspect-ratio: 5 / 3; width: 100%" ... />
aspect-ratio
also pairs great with [object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit)
and [object-position](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position)
, which are quite similar to [background-size](https://developer.mozilla.org/en-US/docs/Web/CSS/background-size)
and [background-position](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position)
for background images, respectively.
.my-image {
aspect-ratio: 5 / 3;
width: 100%;
/* Fill the available space, even if the
image has a different intrinsic aspect ratio */
object-fit: cover;
}
Async image decoding
Additionally, you can specify decoding="async"
to images to allow the browser to move the image decoding off of the main thread. MDN recommends to use this for off-screen images.
<img decoding="async" ... />
Resource hints
One last, and more advanced option, is [fetchpriority](https://web.dev/priority-hints/)
. This can be helpful to hint to the browser if an image is extra high priority, such as your LCP image.
<img fetchpriority="high" ... />
Or, to lower the priority of images, such as if you have images that are above the fold but not of high importance, such as on other pages of a carousel:
<div class="carousel">
<img class="slide-1" fetchpriority="high" />
<img class="slide-2" fetchpriority="low" />
<img class="slide-3" fetchpriority="low" />
</div>
Add your alt
text, kids
Yes, alt
text is critical for accessibility and SEO, and is not to be overlooked:
<img alt="Builder.io drag and drop interface" ... />
Or, for images that are purely presentational (like abstract shapes, colors, or gradients), you can explicitly mark them as presentation only with the role
attribute:
<img role="presentation" ... />
Understanding the sizes
attribute
One important caveat to srcset
attribute mentioned above is that browsers need to know the size an image will render at in order to pick the best sized image to fetch.
Meaning, once the image has rendered, the browser knows its actual display size, multiples that by the pixel density, and fetches the closest possible image in size in the srcset.
But for your initial page load, browsers like chrome have a preload scanner that looks for img tags in the HTML to begin prefetching them immediately.
The thing is - this happens even before the page has rendered. For instance, our CSS hasn't even been fetched yet, so we have no indication as to how the image will display and at what size. As a result, the browser has to make some assumptions.
By default the browser will assume all images are 100vw
- aka the full page width. That's anywhere from a little to a whole lot larger than they actually are. So that is far from optimal.
This is where the sizes attribute comes in handy:
<img
srcset="..."
sizes="(max-width: 400px) 200px, (max-width: 800px) 100vw, 50vw"
...
/>
With this attribute, we can tell the browser at various window sizes, how large to expect our image to be (either exactly, with an exact pixel value like 500px
, or relative to the window, such as 50vw
to say it should display around 50% of the window width).
So in the example above, a 900px
wide screen will not match either of the first two caluses, and instead match the fallback clause that specifies for larger screens assume the image will display at 50vw
.
So since 50vw * 900px = 450px
the browser will aim for a 450px
wide image for a 1x
pixel density display, a 900px
wide image for 2x
pixel density, etc. It will then look for the closest match in the srcset
and use that as the image to prefetch.
Let’s recap
Wow, ok, that was a lot. Let’s put it all together.
Here is a great example of a very optimized image for loading:
<picture>
<source
type="image/avif"
srcset="
/image.avif?width=100 100w,
/image.avif?width=200 200w,
/image.avif?width=400 400w,
/image.avif?width=800 800w
"
/>
<source
type="image/webp"
srcset="
/image.webp?width=100 100w,
/image.webp?width=200 200w,
/image.webp?width=400 400w,
/image.webp?width=800 800w
"
/>
<img
src="/image.png"
srcset="
/image.png?width=100 100w,
/image.png?width=200 200w,
/image.png?width=400 400w,
/image.png?width=800 800w
"
sizes="(max-width: 800px) 100vw, 50vw"
style="width: 100%; aspect-ratio: 16/9"
loading="lazy"
decoding="async"
alt="Builder.io drag and drop interface"
/>
</picture>
For high priority images:
The above image is a good default, and best for images that may be below the fold.
For your highest priority images, you should remove loading="lazy"
and decoding="async"
and consider adding fetchpriority="high"
if this is your absolute highest priority image, like your LCP image:
style="width: 100%; aspect-ratio: 16/9"
- loading="lazy"
- decoding="async"
+ fetchpriority="high"
alt="Builder.io drag and drop interface"
For vectors (like SVGs):
Also, for vector formats such as SVG, we don't need to provide multiple sizes and formats, and can just include the below:
<!-- for SVG -->
<img
src="/image.svg"
style="width: 100%; aspect-ratio: 16/9"
loading="lazy"
decoding="async"
alt="Builder.io drag and drop interface"
/>
Note that we completely removed the <picture>
and <source>
tags, as well as removed the srcset
and sizes
attributes, as they are no longer needed.
For high priority SVGs, the same rules mentioned above apply (remove loading
and decoding
, and optionally add fetchpriority="high"
for your LCP image)
Using an image for a background
Oh yeah, I almost forgot that we started this article by talking about our original use case — a background image.
Now while the image optimization discussed here applies to any type of image you may want to use (such as background, foreground, and so on), it only takes a little bit of CSS (namely some absolute positioning and the object-fit property) to make an img
behave like a background-image
.
Here is a pared down example you can try yourself:
<div class="container">
<picture class="bg-image">
<source type="image/webp" ... />
<img ... />
</picture>
<h1>I am on top of the image</h1>
</div>
<style>
.container {
position: relative;
}
h1 {
position: relative;
}
.bg-image {
position: absolute;
inset: 0;
}
.bg-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
Is using this much additional HTML bad for performance?
Yes and no, but mostly no.
It’s easy to forget just how large images are (in terms of bytes). Adding a few bytes to your HTML can save you thousands, or even millions, of bytes on those images by loading much more optimized versions.
Second, let’s not forget that gzipping is a thing. The additional markup you will add for each image quickly becomes very redundant, which is perfectly suited for gzip to deflate away.
So while DOM bloat and payload size definitely should always be a concern, I would suggest that the tradeoffs are in your favor on this one.
An easier way
These days, you almost never need write all of that stuff by hand. Frameworks like NextJS and Qwik, as well as platforms like Cloudinary and Builder.io, provide image components that make this straightforward, and look instead like the below:
<!-- 😍 -->
<Image
src="/image.png"
alt="Builder.io drag and drop interface" />
And with that, you can get most, if not all, of the above optimizations (including generating all of those different image sizes and formats), automatically.
Note that that in most cases you still need to specify when an image is high priority, like so:
<!-- High priority image -->
<Image
priority
src="/image.png"
alt="Builder.io drag and drop interface" />
And in most cases if you want to use the sizes
attribute you'll need to specify that manually too. Most of these components allow you to pass through options like that directly, like so:
<!-- Manually speify sizes -->
<Image
sizes="(max-width: 500px) 200px, 50vw"
src="/image.png"
alt="Builder.io drag and drop interface" />
The only Image component that I know that can automate setting the sizes
attribute is the one that I made for Builder.io, as we run a puppeteer script in the background to analyze the actual displayed layout of the image at various screen sizes and can generate that accordingly.
Conclusion
Use img
in HTML over CSS background-image
whenever you can. Use lazy loading, srcset
, picture
tags, and the other optimizations we discussed above to deliver images in the most optimal way. Be aware of high-priority as compared to low-priority images and tweak your attributes accordingly.
Or, just use a good framework (like NextJS or Qwik) and/or good platforms (like Cloudinary or Builder.io) and you’ll be covered, the easy way.
About me
Hi! I'm Steve, CEO of Builder.io.
I built our Image
component and image optimization API, and have spent an absurd amount of time performance profiling them across hundreds of real world sites and apps.
Our platform is a way to drag + drop with your components to create pages and other CMS content on your existing site or app, visually.
It’s all API driven and has integrations for all modern frameworks. You may find it interesting or useful: