DEV Community

Cover image for Optimal Images in HTML
Steve Sewell for Builder.io

Posted on • Updated on • Originally published at builder.io

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');
}
Enter fullscreen mode Exit fullscreen mode

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'); }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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

Diagram showing suggesting to not "fetch html -> 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"
  ... 
>
Enter fullscreen mode Exit fullscreen mode

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
  "
  ...
>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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" 
  ...
>
Enter fullscreen mode Exit fullscreen mode

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%" ...>
Enter fullscreen mode Exit fullscreen mode

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; 
}
Enter fullscreen mode Exit fullscreen mode

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" ... >
Enter fullscreen mode Exit fullscreen mode

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" ...>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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"
  ...
>
Enter fullscreen mode Exit fullscreen mode

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" ... >
Enter fullscreen mode Exit fullscreen mode

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"
  ...
>
Enter fullscreen mode Exit fullscreen mode

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"
  ...
>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Gives you this:

Gif of Builder.io

Latest comments (9)

Collapse
 
shinokada profile image
shin

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.

bimgc public/images/bimgc.png -o public/images 
Enter fullscreen mode Exit fullscreen mode

This will convert public/images/bimgc.png to the public/images directory. The following images will be created.

bimgc-100.avif bimgc-200.avif bimgc-400.avif bimgc-800.avif 
bimgc-100.png  bimgc-200.png  bimgc-400.png  bimgc-800.png
bimgc-100.webp bimgc-200.webp bimgc-400.webp bimgc-800.webp
Enter fullscreen mode Exit fullscreen mode
imgtaggen public/images/imgtaggen.png
Enter fullscreen mode Exit fullscreen mode

This will copy the following to your clipboard.

<picture>
  <source
    type="image/avif"
    srcset="public/images/imgtaggen-100.avif?width=100 100w, public/images/imgtaggen-200.avif?width=200 200w, public/images/imgtaggen-400.avif?width=400 400w, public/images/imgtaggen-800.avif?width=800 800w" />
  <source
    type="image/webp"
    srcset="public/images/imgtaggen-100.webp?width=100 100w, public/images/imgtaggen-200.webp?width=200 200w, public/images/imgtaggen-400.webp?width=400 400w, public/images/imgtaggen-800.webp?width=800 800w" />
  <img
    src="public/images/imgtaggen.png"
    srcset="public/images/imgtaggen-100.png?width=100 100w, public/images/imgtaggen-200.png?width=200 200w, public/images/imgtaggen-400.png?width=400 400w, public/images/imgtaggen-800.png?width=800 800w"
    sizes="(max-width: 800px) 100vw, 50vw"
    style="width: 100%; aspect-ratio: 1.791044776119403"
    loading="lazy"
    decoding="async"
    alt="My awesome image"
  />
</picture>
Enter fullscreen mode Exit fullscreen mode

Both CLIs have options you can use.
Please use it and let me know what you think in their GitHub issues.

Collapse
 
harithzainudin profile image
Muhammad Harith Zainudin

interesting! would love to try this

Collapse
 
sabberworm profile image
Raphael Schweikert • Edited

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.

(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 be 100vw, 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 for sizes (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 specifying sizes.

So, for now, specifying sizes is always necessary when you have a srcset 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, specifying sizes might actually be detrimental because it might not exactly match the layout on all breakpoints.

Collapse
 
murdercode profile image
Stefano Novelli

Well written, kudos for you

Collapse
 
annetawamono profile image
Anneta Wamono

This is a great resource for images! Thank you for the article.

Collapse
 
fruntend profile image
fruntend

Сongratulations 🥳! Your article hit the top posts for the week - dev.to/fruntend/top-10-posts-for-f...
Keep it up 👍

Collapse
 
dgenezini profile image
Daniel Genezini

Thank you for this article. It's really helpful!

Collapse
 
myway profile image
MyWay

Thanks for your article. What's the meaning of this line?

sizes="(max-width: 800px) 100vw, 50vw"

Collapse
 
steve8708 profile image
Steve Sewell • Edited

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 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"
  ...
>
Enter fullscreen mode Exit fullscreen mode

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"
  ...
>
Enter fullscreen mode Exit fullscreen mode

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.