· 11 min read

Things to Keep in Mind When Using Images in Frontend Development

This article was auto-translated from Chinese. Some nuances may be lost in translation.

Recently, while reviewing the images on my blog, I noticed that many posts still just threw out an <img src="..."> without setting width and height. When opening them on mobile, the content would jump down as the images loaded.

This phenomenon is called Layout Shift, which refers to unexpected shifts in visual elements on the screen. Common causes include images being too large or slow network speeds, causing the browser to lay out the page before knowing the image dimensions and then readjusting once the image finishes loading. I’ve also seen websites deliberately induce layout shifts to trick users into misclicking ads; whether such designs are truly “deliberate” is up for debate, but it undeniably ruins the user experience.

I happened to come across an article by Jake Archibald summarizing best practices for responsive images, so I decided to organize my own thoughts and approaches. On a side note, I really enjoy HTTP 203 hosted by Jake (a show on Chrome for Developers), which features lots of deep dives into browsers and the web.

Why You Still Must Include width and height

Many people think: “I’ve already set the image width with CSS, so aren’t width and height in HTML redundant? Besides, hardcoding numbers feels inflexible, so why not just leave them out?”

<img src="hero.jpg" alt="hero" />

The problem with this approach is that before the image finishes loading, the browser has no idea how large it is, so it initially renders the layout with a height of 0. Once the image download progresses enough to read the intrinsic dimensions, the browser retroactively allocates space for it.

As a result, you see the screen suddenly jump downward. This is known as CLS (Cumulative Layout Shift), a Core Web Vitals metric that directly affects SEO rankings.

The proper way to write it is:

<img src="hero.jpg" width="1600" height="900" alt="hero" />

By specifying width="1600" height="900", the browser immediately knows from the HTML that the aspect ratio of the image is 16:9. Even if your CSS applies width: 100%, the browser can still calculate the corresponding height based on this ratio and reserve the space upfront. Once the image finishes loading, there won’t be any layout jump.

The width and height attributes on HTML are not absolute display dimensions; rather, they serve to let the browser calculate the aspect ratio. Your CSS will still take charge of the actual rendered size:

img {
  width: 100%;
  height: auto;
}

Since 2020, Chrome, Firefox, and Safari all automatically infer CSS aspect-ratio from the width and height attributes1, which is equivalent to writing behind the scenes:

img[width][height] {
  aspect-ratio: attr(width) / attr(height);
}

So as long as you properly fill in width and height, CSS can scale the image however you like, and CLS is resolved at the same time. Unless your layout is entirely dynamic and you don’t even know the aspect ratio (we’ll cover how to handle that later), you should default to adding these two attributes. (Though I often get lazy too)

When to Use CSS aspect-ratio

There are scenarios where you cannot obtain the intrinsic dimensions of an image. For instance, dynamic URLs, CMS fields that omit dimensions, or cover elements built with a <div> and background-image. This is where CSS aspect-ratio comes in:

.cover {
  aspect-ratio: 16 / 9;
  width: 100%;
  background-size: cover;
}

aspect-ratio is incredibly convenient when you need to constrain an element’s proportions, such as square cards, video thumbnails, or wrapping an <iframe> in a 16:9 container. Doing this used to require hacks like padding-top: 56.25%; now it takes just one line.

Back to images, the general rule of thumb is:

  • If you know the image’s intrinsic dimensions → specify them in HTML width and height
  • If you don’t, but you can dictate its display ratio → use CSS aspect-ratio
  • They don’t conflict, and using them together works fine too

Switching Formats: WebP and AVIF

JPEG is a specification from 1992—older than I am. Modern formats can be significantly smaller:

  • WebP — Introduced by Google and released in 2010. It yields smaller file sizes than JPEG at comparable quality2, and is supported across all major browsers3.
  • AVIF — An image format based on the AV1 video codec, introduced in 2019. For photographic content at medium to high quality, it is significantly smaller than JPEG4. Safari has supported it since version 16, with Chrome and Firefox supporting it even earlier; it is now broadly covered across all modern browsers5.

You can also check out The Secret Behind JPEG Compression to learn more about how image compression works.

My current approach is: use AVIF whenever possible, WebP as a fallback, and JPEG/PNG as the final fallback. The actual compression gains vary by image content—flat graphics with fewer gradients (such as UI screenshots) compress exceptionally well in WebP/AVIF; images with high-frequency details (such as landscapes) see smaller differences, but it is usually still well worth it.

To implement format fallbacks, we use <picture>.

Combining picture, source, and srcset

The concept of <picture> is quite straightforward: define several <source> elements, and the browser picks the first supported one from top to bottom, falling back to <img> if none are supported.

<picture>
  <source srcset="hero.avif" type="image/avif" />
  <source srcset="hero.webp" type="image/webp" />
  <img src="hero.jpg" width="1600" height="900" alt="hero" />
</picture>

Up to this point, we’ve only solved the format issue, not the sizing issue. A mobile screen is only around 400px wide; downloading a 1600px wide image wastes bandwidth. This is where srcset and sizes come into play.

Providing Multiple Resolutions with srcset

There are two ways to write srcset, differing in their descriptors:

  • w descriptor: Specifies the image’s actual width in pixels. Used alongside sizes, allowing the browser to select the best fit based on layout width and DPR.
  • x descriptor: Specifies the device pixel ratio (DPR) multiplier the image is intended for. Suitable for fixed-size layouts targeting Retina displays.

The x descriptor looks like this, which is relatively straightforward:

<img
  src="avatar.jpg"
  srcset="avatar.jpg 1x, avatar@2x.jpg 2x, avatar@3x.jpg 3x"
  width="80"
  height="80"
  alt="avatar"
/>

But if your image displays at completely different widths on different screens (e.g., full width on mobile, fixed at 800px on desktop), the x descriptor falls short. You’ll need the w descriptor paired with sizes:

<img
  src="hero-800.jpg"
  srcset="
    hero-400.jpg   400w,
    hero-800.jpg   800w,
    hero-1600.jpg 1600w
  "
  sizes="(max-width: 600px) 100vw, 800px"
  width="1600"
  height="900"
  alt="hero"
/>

sizes informs the browser of “how wide this image will actually render in the layout.” For instance, the example above specifies:

  • When viewport width ≤ 600px → image takes up 100vw (full viewport width)
  • Otherwise → image has a fixed width of 800px

With this information, taking into account the device’s DPR (device pixel ratio, the ratio between physical pixels and CSS pixels), the browser selects the most suitable variant from srcset to download. For example, an iPhone screen with a 390px width and DPR of 3 needs 390 × 3 = 1170px resolution, so it will pick the 1600w candidate. A desktop displaying at 800px with a DPR of 2 needs 1600px, so it will also pick 1600w. If it’s a desktop at 800px with a DPR of 1, 800w is sufficient.

Combining Format and Size

Combining <picture> with srcset looks like this:

<picture>
  <source
    type="image/avif"
    srcset="hero-400.avif 400w, hero-800.avif 800w, hero-1600.avif 1600w"
    sizes="(max-width: 600px) 100vw, 800px"
  />
  <source
    type="image/webp"
    srcset="hero-400.webp 400w, hero-800.webp 800w, hero-1600.webp 1600w"
    sizes="(max-width: 600px) 100vw, 800px"
  />
  <img
    src="hero-800.jpg"
    srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1600.jpg 1600w"
    sizes="(max-width: 600px) 100vw, 800px"
    width="1600"
    height="900"
    alt="hero"
  />
</picture>

Each <source> represents all sizes of the same image under a specific format. In practice, you might need to generate 3 formats × 3 sizes = 9 files. This is typically handled by build tools or CDNs; common options include Astro’s <Image>, Next.js’s next/image, or dedicated image CDN services.

Nowadays, I frequently use Cloudflare Images. It doesn’t require uploading pre-built AVIF/WebP/JPEG variants; instead, it inspects the request’s Accept header to detect supported formats, converts on demand, and returns the image while maintaining CDN caching. Compared to generating all variations at build time, dynamic image format conversion avoids adding an extra step to your workflow.

Swapping Images for Mobile (Art Direction)

Everything discussed so far assumes the same image across different resolutions. However, there are times when you want a completely different image for mobile versus desktop—a concept known as art direction.

For example, desktop might feature a 16:9 landscape photo, but scaling it down on mobile makes the subject tiny and hard to see. A better approach is switching to a cropped portrait image on mobile with the subject enlarged.

This is another use case for <picture>. Just add a media attribute to <source>:

<picture>
  <!-- Mobile: Portrait crop -->
  <source
    media="(max-width: 600px)"
    type="image/avif"
    srcset="hero-mobile-400.avif 400w, hero-mobile-800.avif 800w"
    sizes="100vw"
  />
  <source
    media="(max-width: 600px)"
    type="image/webp"
    srcset="hero-mobile-400.webp 400w, hero-mobile-800.webp 800w"
    sizes="100vw"
  />

  <!-- Desktop: Original landscape -->
  <source
    type="image/avif"
    srcset="hero-800.avif 800w, hero-1600.avif 1600w"
    sizes="800px"
  />
  <source
    type="image/webp"
    srcset="hero-800.webp 800w, hero-1600.webp 1600w"
    sizes="800px"
  />

  <img
    src="hero-800.jpg"
    width="1600"
    height="900"
    alt="hero"
  />
</picture>

As before, the browser evaluates <source> tags from top to bottom, picking the first match—so sources with media should be placed first. Mobile screens will match the mobile sources first, while desktop falls through to the later desktop versions.

One nuance to watch out for: the mobile image might be portrait, meaning its aspect ratio differs from the 16:9 defined on <img>. In such cases, using CSS aspect-ratio to manage container proportions or switching them across breakpoints with CSS will be more reliable than relying solely on fixed attributes on <img>.

Finishing Up with object-fit

When your reserved space’s aspect ratio differs from the image’s intrinsic ratio (for example, a card forced into 1:1 while the image is 16:9), object-fit comes into play:

img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

object-fit works on both <img> and <video>. The two most commonly used values are:

  • cover — Fills the container, cropping the image if necessary. Typically used for card thumbnails and avatars.
  • contain — Displays the entire image, leaving empty space if aspect ratios don’t match. Best for logos, product photos, and other content that must not be cropped.

Pairing it with object-position allows adjusting the crop alignment (e.g., object-position: top ensures cropping starts from the top, avoiding chopping off someone’s chin).

loading and fetchpriority

In addition to dimensions and formats, two other attributes have become standard practices:

<img
  src="hero.jpg"
  width="1600"
  height="900"
  loading="lazy"
  decoding="async"
  alt="hero"
/>
  • loading="lazy" — Defers downloading until the image approaches the viewport. Particularly effective for long articles, product lists, and masonry grids, saving substantial initial bandwidth.
  • decoding="async" — Prevents image decoding from blocking the main thread. Default behavior is often close to async, but explicitly specifying it avoids synchronous decoding paths in certain browsers.
  • fetchpriority="high" — Conversely, add this to critical images (like the LCP hero) so the browser prioritizes their download.

Over the past few years, browsers have added many built-in mechanisms for image optimization. Unlike the past, where you had to write complex JavaScript to optimize images yourself, it is far easier now. All frontend developers need to do is provide the right attributes, and let the browser take care of the rest.

Footnotes

  1. Setting Height And Width On Images Is Important Again — Smashing Magazine ↩

  2. WebP Compression Study — Google ↩

  3. Can I Use — WebP ↩

  4. AVIF for Next-Generation Image Coding — Netflix Tech Blog ↩

  5. Can I Use — AVIF ↩

Related Posts

Explore Other Topics