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? In more ways than one.
Why you should (generally) avoid background-image
in CSS
Optimal image sizing
Outside of using SVGs, theres 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
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 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
fetch CSS -> fetch image" and instead "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 preconnecting to origins. But, as you will 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 - like all rules, there are exceptions here. 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 using an img
tag, if not 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” (aka that will be in the browser’s viewport immediately on first load). That will help ensure your most critical images load as immediately as possible, and all others will load only as needed.
PS: loading=lazy
also works on iframes
😍
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
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 (aka 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 format 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
and object-position
(which are quite similar to background-size
and background-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
. 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: 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).
The above code example tells the browser for any screen up to 800px
wide, assume the image fills the entire screen (100vw
), and for any other (larger) screen size, assume the image fills half the screen (50vw
) and prefetch accordingly.
So in that example, a 900px
wide screen will not be caught by the first clause ((max-width: 800px)
) and instead match the fallback clause that specifies for large 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.
We can add as many clauses as we like, such as:
<img
srcset="..."
sizes="(max-width: 400px) 200px, (max-width: 600px) 20vw, 50vw"
...
>
In the above example, for instance, a 350px
wide screen will fetch a 200px
wide image per this clause matching the current screen size: (max-width: 400px) 200px
. If that screen has a 2x
pixel density, it still knows the image will display at 200px
, but will multiply that by 2 and fetch a 400px
image to match this higher resolution.
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>
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"
Using an image for a background
Oh yeah, almost forgot that we started this article by talking about our original use case was a background image.
Now while the image optimization discussed here applies to any type of image you may want to use (background, foreground, etc), it only takes a little bit of CSS (namely some absolute positioning and the object-fit property) to make an img
be able to be behave like a background-image
Here is a simplified 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 above crazy stuff by hand. Frameworks like NextJS and Qwik, as well as platforms like Cloudinary and Builder.io, provide image components that make this easy, 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.
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 vs 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. So this:
import { BuilderComponent, registerComponent } from '@builder.io/react'
import { Hero, Products } from './my-components'
// Dynamically render compositions of your components
export function MyPage({ json }) {
return <BuilderComponent content={json} />
}
// Use your components in the drag and drop editor
registerComponent(Hero)
registerComponent(Products)
Gives you this:
Top comments (9)
Inspired by this article, I created two CLIs, bimgc and imgtaggen.
imgtaggen is for generating a responsive image tag with support for AVIF and WebP formats. It will also calculate image ratio.
bimgc is for converting PNG and JPG images to AVIF and WebP format with various sizes and saves them in a specified output directory. The output images are named based on the input file and include information about their size and format.
This will convert public/images/bimgc.png to the public/images directory. The following images will be created.
This will copy the following to your clipboard.
Both CLIs have options you can use.
Please use it and let me know what you think in their GitHub issues.
interesting! would love to try this
Thanks for your article. What's the meaning of this line?
sizes="(max-width: 800px) 100vw, 50vw"
Great Q, I've added a new section on this called "Understanding the sizes attribute" to the post. Since it's not easy to link direct to that section, will paste here too:
Understanding the
sizes
attributeOne 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:
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 as50vw
to say it should display around 50% of the window width).The above code example tells the browser for any screen up to
800px
wide, assume the image fills the entire screen (100vw
), and for any other (larger) screen size, assume the image fills half the screen (50vw
) and prefetch accordingly.So in that example, a
900px
wide screen will not be caught by the first clause ((max-width: 800px)
) and instead match the fallback clause that specifies for large screens assume the image will display at50vw
. So since50vw * 900px = 450px
the browser will aim for a450px
wide image for a1x
pixel density display, a900px
wide image for2x
pixel density, etc. It will then look for the closest match in thesrcset
and use that as the image to prefetch.We can add as many clauses as we like, such as:
In the above example, for instance, a
350px
wide screen will fetch a200px
wide image per this clause matching the current screen size:(max-width: 400px) 200px
. If that screen has a2x
pixel density, it still knows the image will display at200px
, but will multiply that by 2 and fetch a400px
image to match this higher resolution.Well written, kudos for you
Thank you for this article. It's really helpful!
Сongratulations 🥳! Your article hit the top posts for the week - dev.to/fruntend/top-10-posts-for-f...
Keep it up 👍
(I take “currently displaying 200px wide” to mean 200px is the displayed size of the image, not the viewport size).
For a long time, I, too, was under the false impression that this was true.
However, when I finally tested this, I figured out that none of the browsers behave this way: i.e. none of the browsers take current layout into account when determining which image rendition to load. Not for lazily-loaded images, not after resolution changes/orientation changes/resizes, definitely not for the initial load of eagerly-loaded images.
The reason for this is that the spec actually doesn’t allow such dynamic behavior: the fallback value for
sizes
is currently defined to be100vw
, meaning the browser needs to treat the image as if it filled the entire viewport width on all breakpoints. The spec has no provisions in place for the browser to override this if it already knows the layout bounds of the image.However, there is discussion going on attempting to change that. The proposed solution, though not fully-fleshed out, would consist of adding a value of
auto
forsizes
(and making that the default, at least for lazily-loaded images).The verdict is still out on whether
auto
should also become the default (or, indeed, should even be valid) for eagerly-loaded images. I would be all for that, as it would allow the browser to take the known layout size into account when loading a new rendition (e.g. in response to user resizes/orientation changes/moving to a high-dpi monitor/zooming in or out, or when the layout is dynamically changed by JS).There seems to be consensus, however, on not letting the browser override an explicit set of instructions for
sizes
since the browser cannot anticipate future layout changes that the website author maybe already had in mind when specifyingsizes
.So, for now, specifying
sizes
is always necessary when you have asrcset
and your image does not cover the full viewport width on all breakpoints – otherwise the browser will unnecessarily load images that are too large. This might change in the future when the proposal is adopted. With that, specifyingsizes
might actually be detrimental because it might not exactly match the layout on all breakpoints.This is a great resource for images! Thank you for the article.