Mert Özen Further With Every Line
Frontend HTML5 and Semantic HTML — #08

Images and Media: img, picture, srcset/sizes, video, audio, track and iframe

Mert Özen Nov 14, 2026 17 min 6 views
Images and Media: img, picture, srcset/sizes, video, audio, track and iframe

Give every img a width and height, choose alt by the image's job on the page, pair srcset with sizes when the image size changes with the screen, and never put loading="lazy" on an image on the first screen. In this post we measure these rules in Chromium and WebKit: a 0.14 layout shift from an image without dimensions, sizes="auto", AVIF/WebP/JPEG XL order in picture, the half-second delay of a lazy LCP image, subtitles with track and the iframe title.

Version note: This post follows the November 2026 state of the HTML (HyperText Markup Language) Living Standard and Baseline; the examples were tested with Chrome 158, WebKit 27.2 (Playwright 1.65) and Nu Html Checker 26.11.9, and Firefox and Edge support is taken from web-features data and the browsers' announcements.

Guess First

Bookstore's book detail page has the same cover image twice. The first has no width and height, the second does; CSS (Cascading Style Sheets) makes both flexible with max-width: 100%; height: auto:

{{site/cls.html}}

On a slow connection the images arrive after 800 ms. Meanwhile the user is reading the price. When the images arrive, does the price line stay where it is or does it move? If it moves, by how many pixels? And does the "In stock" line stay put because the second image's space was reserved from the start?

With the previous post, "Native Interactive Elements 2: select, datalist, Customizable Select and output/progress/meter", we finished the interactive elements. In this post we move to what is usually the heaviest part of the page: images, video, audio and iframe. Their HTML side is just a few attributes, but written wrong they make the page jump, make the user download the wrong image, or make a screen reader say "image" and move on. Image format choice, CDN (Content Delivery Network) transformation and LCP (Largest Contentful Paint) strategy are in the upcoming Web Performance series; here we cover the markup.

width and height: Preventing Layout Shift

For the measurements I wrote a small Node server that serves the site folder and adds an artificial delay to the extensions I choose. All the scripts in this post use it:

const http = require('http');
const fs = require('fs');
const path = require('path');

const types = {
  '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.jpg': 'image/jpeg', '.webp': 'image/webp',
  '.avif': 'image/avif', '.jxl': 'image/jxl', '.webm': 'video/webm', '.mp4': 'video/mp4', '.vtt': 'text/vtt',
};

// serves the site folder; adds an artificial delay per extension and records every request
module.exports = function serve(port, { delays = {}, log = [] } = {}) {
  const server = http.createServer((req, res) => {
    const file = path.join(__dirname, 'site', decodeURIComponent(new URL(req.url, 'http://x').pathname));
    log.push(req.url);
    if (!fs.existsSync(file)) { res.statusCode = 404; return res.end(); }
    res.setHeader('Content-Type', types[path.extname(file)] ?? 'application/octet-stream');
    setTimeout(() => res.end(fs.readFileSync(file)), delays[path.extname(file)] ?? 0);
  });
  return new Promise(resolve => server.listen(port, () => resolve(server)));
};
const { chromium } = require('playwright');
const serve = require('./serve');

(async () => {
  const server = await serve(18884, { delays: { '.jpg': 800 } });
  const browser = await chromium.launch();
  const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
  await page.goto('http://localhost:18884/cls.html', { waitUntil: 'commit' });
  await page.waitForSelector('#stock');
  const top = () => page.evaluate(() => ['price', 'stock'].map(id =>
    `${id}.top=${Math.round(document.getElementById(id).getBoundingClientRect().top)}`).join(' '));
  console.log('before images:', await top());
  await page.waitForLoadState('load');
  console.log('after images: ', await top());
  // Chromium's Layout Instability API: the score of each shift
  const shifts = await page.evaluate(() => new Promise(resolve =>
    new PerformanceObserver(list => resolve(list.getEntries().map(e => e.value.toFixed(4))))
      .observe({ type: 'layout-shift', buffered: true })));
  console.log('layout shifts:', shifts.join(', '));
  await browser.close();
  server.close();
})();
before images: price.top=114 stock.top=768
after images:  price.top=700 stock.top=1354
layout shifts: 0.1407

The price line moved from pixel 114 to pixel 700, that is 586 pixels down. The cause is the first image: while reading the HTML, the browser didn't know its size, gave it zero height, and when the image arrived it pushed everything below it down. The "In stock" line moved by the same amount, but note: the distance between price and stock is 654 pixels both before and after. Thanks to the second image's width="400" height="600" attributes, the browser reserved its space before the image arrived; that image shifted nothing.

The mechanism is that browsers derive an aspect-ratio from the width and height attributes. Because CSS has height: auto, the 2:3 ratio is kept even when the image shrinks to fit the screen, and the space is reserved correctly. So these attributes don't lock the image to a fixed size; they tell the browser the ratio. Write them even in a responsive design.

The last line is the score measured by Chromium's Layout Instability API (Application Programming Interface): 0.1407. The CLS (Cumulative Layout Shift) metric in Core Web Vitals is calculated from these scores, and by web.dev's threshold anything below 0.1 counts as "good". A single image without dimensions pushed the page above that threshold on a 1280×800 screen. The details of the metric are in the Web Performance series.

alt Text: Informative, Decorative and Functional Images

alt is the text that stands in for the image: a screen reader reads it, the browser shows it if the image fails to load, and a search engine uses it as the image's meaning. But the right alt depends not on what the image is, but on what it does on the page. Six variants:

{{site/alt.html}}
const pw = require('playwright');
const serve = require('./serve');

(async () => {
  const server = await serve(18889);
  for (const name of ['chromium']) {
    const browser = await pw[name].launch();
    const page = await browser.newPage();
    await page.goto('http://localhost:18889/alt.html');
    await page.waitForLoadState('networkidle');
    console.log(`--- ${name} ${browser.version()}`);
    // Playwright's ARIA snapshot: what remains in the accessibility tree?
    console.log(await page.locator('main').ariaSnapshot());
    console.log('broken:', await page.locator('#broken').evaluate(i =>
      `${i.currentSrc.split('/').pop()} naturalWidth=${i.naturalWidth}`));
    await browser.close();
  }
  server.close();
})();
--- chromium 158.0.8127.18
- main:
  - heading "Clean Architecture" [level=1]
  - img "Cover of Clean Architecture"
  - img
  - img "image"
  - link "Cart, 2 items":
    - /url: /cart
    - img "Cart, 2 items"
  - img "Cover of Refactoring"
broken: refactoring-200.avif naturalWidth=0

An informative image (a book cover) gets an alt that describes the content: "Cover of Clean Architecture". A decorative image gets alt="" and, as the output shows, drops out of the accessibility tree entirely; a screen reader skips it. A divider, a background ornament or an icon whose text is already written next to it falls into this group. An image with no alt at all sits between the two, in the worst place: an img with no name. Some screen readers read the file name in this case, and "clean-architecture-200 jpg" tells nobody anything.

alt="image" misleads by existing: a screen reader already says "image", and adding "image" on top is noise, not information. Words such as "picture", "photo" or "cover image" belong to the same category; write what the image shows, not what it is.

For a functional image the rule changes. The cart icon is the only content of a link; in the output, the link's name comes straight from the image's alt: "Cart, 2 items". Here the alt must describe what the link does, not the image. Had you written "Shopping cart icon", that would be the link's name.

The last line is a bridge to the next section: the img inside the picture kept its name but failed to load (naturalWidth=0). We'll see why below.

srcset and sizes: Density or Width?

Sending the same cover file to a 1x desktop screen and a 3x phone is either blurry for one or needlessly large for the other. srcset gives the browser candidate files, and the browser makes the choice. There are two kinds of descriptor:

{{site/responsive.html}}
const pw = require('playwright');
const serve = require('./serve');

const cases = [
  { width: 1280, height: 800, dpr: 1 },
  { width: 1280, height: 800, dpr: 2 },
  { width: 390, height: 844, dpr: 3 },
];

(async () => {
  const server = await serve(18885);
  for (const name of ['chromium', 'webkit']) {
    const browser = await pw[name].launch();
    console.log(`--- ${name} ${browser.version()}`);
    for (const { width, height, dpr } of cases) {
      // a new context per case: a large image in the cache must not affect the choice
      const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: dpr });
      const page = await context.newPage();
      await page.goto('http://localhost:18885/responsive.html');
      await page.waitForLoadState('networkidle');
      const picked = await page.evaluate(() => ['density', 'width', 'format'].map(id =>
        `${id}=${document.getElementById(id).currentSrc.split('/').pop()}`).join(' '));
      const decoded = await page.locator('#format').evaluate(i => i.naturalWidth);
      console.log(`${width}px @${dpr}x  ${picked} (naturalWidth=${decoded})`);
      await context.close();
    }
    await browser.close();
  }
  server.close();
})();
--- chromium 158.0.8127.18
1280px @1x  density=clean-architecture-200.jpg width=clean-architecture-400.jpg format=clean-architecture-400.jxl (naturalWidth=400)
1280px @2x  density=clean-architecture-400.jpg width=clean-architecture-800.jpg format=clean-architecture-400.jxl (naturalWidth=400)
390px @3x  density=clean-architecture-400.jpg width=clean-architecture-800.jpg format=clean-architecture-400.jxl (naturalWidth=400)
--- webkit 27.2
1280px @1x  density=clean-architecture-200.jpg width=clean-architecture-400.jpg format=clean-architecture-400.jxl (naturalWidth=400)
1280px @2x  density=clean-architecture-400.jpg width=clean-architecture-800.jpg format=clean-architecture-400.jxl (naturalWidth=400)
390px @3x  density=clean-architecture-400.jpg width=clean-architecture-800.jpg format=clean-architecture-400.jxl (naturalWidth=400)

Density descriptors (1x, 2x) are for images whose on-screen size is fixed, such as the 200-pixel thumbnail in the catalog. The 1x screen got the 200 file, the 2x screen the 400 file; the 3x phone got 400 again, because the best candidate is 2x. If a larger file is not in the list, the browser can't invent it.

Width descriptors (200w, 400w, 800w) are for images whose size changes with the screen, and they come with sizes. sizes answers the question "how wide will this image be drawn on screen" for the browser, because when it picks the file the browser has not applied CSS yet. Our value is "300 pixels on screens wider than 1000 pixels, otherwise half the screen". On a 1280-pixel 1x screen 300 pixels are needed; 400w was chosen. On the same screen at 2x, 600 pixels are needed; 800w was chosen. On the 390-pixel 3x phone the image is half the screen, 195 CSS pixels, which means 585 physical pixels; 800w again. Chromium and WebKit made the same decision.

If you leave out sizes, the default is 100vw: the browser assumes the image covers the whole screen and downloads the largest candidate. That is why the Nu Html Checker flags a srcset with width descriptors but no sizes as an error; you'll see its output in "In Production".

Format Fallback with picture: AVIF, WebP and JPEG XL

The second job of picture is format choice. On the page above, the cover is offered in four formats: JPEG XL, the new format of the JPEG (Joint Photographic Experts Group) family, AVIF (AV1 Image File Format), WebP and classic JPEG. The browser reads the source elements from top to bottom and picks the first one whose type it supports; if it supports none, it falls back to the img's src. In the output, both engines picked JPEG XL, which comes first, and decoded the file (naturalWidth=400).

Support in November 2026 is interesting. AVIF is widely available since July 2026, WebP since long before. JPEG XL had partial support in Safari since 17; Chrome 155 and Firefox 158 turned it on by default in October 2026. Edge, although based on Chromium, does not display it yet, so JPEG XL is not Baseline. An Edge user skips JPEG XL on this page and falls back to AVIF. picture exists exactly for this: a browser that supports the new format takes it, one that doesn't quietly moves to the next.

There are two subtle points. First, the browser picks the first file it supports, not the smallest. In this post's sample files, I converted JPEG XL losslessly from JPEG, so it is 2464 bytes while AVIF is 527 bytes; with this order both engines downloaded the larger file. Order the sources by the sizes you measure on your own images, not by the format's name. Second, and more dangerous: if the chosen source fails to load, the browser does not move on to the next source. For the "Refactoring" cover in alt.html, the AVIF file did not exist on the server; the browser picked AVIF because it supports it, got a 404 and never went back to the JPEG. The fallback works against format support, not against a missing file. Verify with a test that your build pipeline produces every format at every size.

loading="lazy" and sizes="auto"

loading="lazy" defers downloading an image until the user gets close to it. It is the right choice for the "Customers also bought" images far below the fold; downloading them on first load makes them compete for bandwidth with the work on the first screen. It works for both img and iframe and is widely available since June 2026.

Lazy images have one more advantage: because the browser downloads them after layout, it knows the image's real width on screen. sizes="auto" uses this: instead of you calculating sizes by hand, the browser takes the width from layout. For the 150-pixel thumbnails in the catalog I tried two pages, one without sizes and one with sizes="auto, 150px":

{{site/thumbs-auto.html}}
const pw = require('playwright');
const serve = require('./serve');

(async () => {
  const server = await serve(18886);
  for (const name of ['chromium', 'webkit']) {
    const browser = await pw[name].launch();
    console.log(`--- ${name} ${browser.version()}`);
    for (const file of ['thumbs-nosizes.html', 'thumbs-auto.html']) {
      for (const dpr of [1, 2]) {
        const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: dpr });
        const page = await context.newPage();
        await page.goto(`http://localhost:18886/${file}`);
        await page.waitForLoadState('networkidle');
        const src = await page.locator('img').evaluate(i => i.currentSrc.split('/').pop());
        console.log(`${file.padEnd(20)} @${dpr}x -> ${src}`);
        await context.close();
      }
    }
    await browser.close();
  }
  server.close();
})();
--- chromium 158.0.8127.18
thumbs-nosizes.html  @1x -> clean-architecture-800.jpg
thumbs-nosizes.html  @2x -> clean-architecture-800.jpg
thumbs-auto.html     @1x -> clean-architecture-200.jpg
thumbs-auto.html     @2x -> clean-architecture-400.jpg
--- webkit 27.2
thumbs-nosizes.html  @1x -> clean-architecture-800.jpg
thumbs-nosizes.html  @2x -> clean-architecture-800.jpg
thumbs-auto.html     @1x -> clean-architecture-200.jpg
thumbs-auto.html     @2x -> clean-architecture-400.jpg

On the page without sizes, both engines downloaded the 800-pixel file for a 150-pixel image, because the default is 100vw. With auto, 200w was chosen on the 1x screen and 400w on the 2x screen. The second value in the list (150px) is a fallback for browsers that don't know auto. sizes="auto" only works on images with loading="lazy". After Chrome 126 and Firefox 150 it arrived with Safari 27, and web.dev's September 2026 roundup declared it Baseline newly available. But as of November 2026, web-features data counts Safari as unsupported again: in Safari 27, sizes="auto" breaks how SVG (Scalable Vector Graphics) images are drawn with object-fit (WebKit bug 324661), and the fix is not in a released version yet. So the official status is currently limited availability, which is why the fallback value in the list matters.

Lazy has a cost too, and it is easy to measure. I put the same large cover on one page normally and on another with loading="lazy"; both pages have a render-blocking stylesheet that arrives at 500 ms, and the images arrive at 800 ms:

const { chromium } = require('playwright');
const serve = require('./serve');

(async () => {
  const server = await serve(18887, { delays: { '.css': 500, '.jpg': 800 } });
  const browser = await chromium.launch();
  for (const file of ['hero-eager.html', 'hero-lazy.html']) {
    const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
    await page.goto(`http://localhost:18887/${file}`);
    await page.waitForLoadState('networkidle');
    const r = await page.evaluate(() => new Promise(resolve => {
      const img = performance.getEntriesByType('resource').find(e => e.name.includes('.jpg'));
      new PerformanceObserver(list => {
        const lcp = list.getEntries().at(-1);
        resolve({ requestStart: Math.round(img.startTime), lcp: Math.round(lcp.startTime), element: lcp.element?.tagName });
      }).observe({ type: 'largest-contentful-paint', buffered: true });
    }));
    console.log(`${file.padEnd(16)} image request at ${r.requestStart} ms, LCP ${r.element} at ${r.lcp} ms`);
    await page.close();
  }
  await browser.close();
  server.close();
})();
hero-eager.html  image request at 6 ms, LCP IMG at 820 ms
hero-lazy.html   image request at 520 ms, LCP IMG at 1336 ms

The normal image's request started at 6 ms: the browser's preload scanner found it while reading the HTML and started downloading without waiting for the stylesheet. The lazy image's request started at 520 ms, after the stylesheet arrived and layout was calculated. The cover, the largest content on the page, was painted half a second later: LCP went from 820 ms to 1336 ms. Never put loading="lazy" on an image visible on the first screen, especially one that can be the LCP.

video, audio and track: Subtitles

The book page has a short interview video with the author, with subtitles in two languages:

{{site/book.html}}

controls shows the browser's playback controls; without it the user can't pause the video and can't reach it with a keyboard, so unless you wrote your own controls, always add it. preload="metadata" tells the browser to fetch only information such as duration and dimensions; poster is the image shown before playback. width and height reserve space here too. With several source elements, the browser picks the first format it can play, as in picture. The final paragraph is a fallback for browsers that don't support video at all.

track attaches a text file in WebVTT (Web Video Text Tracks) format to the video. The file looks like this:

{{site/media/author-interview.tr.vtt}}

kind="subtitles" is for a translation of the dialogue; if the text also describes sound effects and who is speaking, for deaf and hard-of-hearing users, the right kind is captions. srclang gives the language, label the name shown in the menu, and default the track to enable by default. A script that loads the page, then scrolls down and records the requests:

const pw = require('playwright');
const serve = require('./serve');

(async () => {
  for (const name of ['chromium', 'webkit']) {
    const log = [];
    const server = await serve(18888, { log });
    const browser = await pw[name].launch();
    const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
    console.log(`--- ${name} ${browser.version()}`);
    await page.goto('http://localhost:18888/book.html');
    await page.waitForTimeout(1000);
    console.log('requests on load:', log.splice(0).join(' '));

    await page.mouse.wheel(0, 4000);
    await page.waitForTimeout(1000);
    console.log('after scroll:    ', log.splice(0).join(' '));

    const video = await page.locator('video').evaluate(v => ({
      src: v.currentSrc.split('/').pop(),
      tracks: [...v.textTracks].map(t => `${t.language}/${t.mode}/cues=${t.cues ? t.cues.length : null}`),
      firstCue: v.textTracks[0].cues?.[0]?.text,
    }));
    console.log('video:', JSON.stringify(video));
    await browser.close();
    server.close();
  }
})();
--- chromium 158.0.8127.18
requests on load: /book.html /covers/clean-architecture-400.jpg /media/author-interview-poster.jpg /media/author-interview.en.vtt /media/author-interview.webm /embed/store-map.html
after scroll:     /covers/clean-architecture-200.jpg?b=1 /covers/clean-architecture-200.jpg?b=2
video: {"src":"author-interview.webm","tracks":["en/showing/cues=2","tr/disabled/cues=null"],"firstCue":"I started this book after twenty years of maintenance work."}
--- webkit 27.2
requests on load: /book.html /covers/clean-architecture-400.jpg /media/author-interview-poster.jpg /media/author-interview.webm
after scroll:     /covers/clean-architecture-200.jpg?b=1 /covers/clean-architecture-200.jpg?b=2 /embed/store-map.html
video: {"src":"author-interview.webm","tracks":["en/disabled/cues=null","tr/disabled/cues=null"]}

Chromium enabled the English track because it is default (showing) and downloaded the WebVTT file; the first cue reads correctly. The Turkish track is disabled and its file was never downloaded: the browser waits until the user picks that language. WebKit did not enable the English track either. According to the spec, default is not a guarantee: if the user's preferences point to another track as more suitable, or the user turned subtitles off, the browser follows them. Safari makes the subtitle decision together with the operating system's accessibility settings. Don't build a design that assumes subtitles will be on.

If the track file comes from another origin (e.g. a CDN), the video needs the crossorigin attribute and the server needs a CORS (Cross-Origin Resource Sharing) header; otherwise the subtitles silently fail to load. audio follows the same model: controls, preload, several source elements. For a podcast or an audiobook sample, giving the transcript as text on the page is a more accessible solution than track.

About autoplay: browsers block a video with sound from starting on its own before the user interacts with the page. A muted video, or one without an audio track, can autoplay; in Safari it also needs playsinline to play inline. So the right trio for a background video is autoplay muted playsinline, and leaving the user a way to pause it is still your job.

iframe: title and loading="lazy"

At the bottom of the book page, the store map is in an iframe. Two attributes matter. title is the iframe's accessible name; it is what a screen reader user sees when listing the frames on the page. It should be "Map of the Bookstore shop", not "iframe" or nothing.

loading="lazy" is even more valuable on an iframe than on an image, because a map or video embed brings its own scripts, fonts and images. But the measurement showed a surprise: Chromium held the images until scrolling, yet requested the iframe on first load. WebKit requested both after scrolling. Lazy means "load when near", not "load when visible", and the "near" distance varies with the browser, the kind of resource and the connection speed. When I made the page 800vh tall instead of 400vh, Chromium held the iframe until scrolling as well. Lazy is a hint, not a timing guarantee.

What a third-party iframe is allowed to do (sandbox, allow) and rel security are covered in the "Third-Party Scripts, iframes and postMessage" post of the Frontend Security series.

In Production

I ran all the pages in this post through the Nu Html Checker:

docker run --rm -v "${PWD}/site:/site" ghcr.io/validator/validator:latest vnu --errors-only --skip-non-html /site
"file:/site/thumbs-nosizes.html":16.9-20.72: error: When the “srcset” attribute has any image candidate string with a width descriptor, the “sizes” attribute must also be specified.
"file:/site/alt.html":12.9-12.94: error: An “img” element must have an “alt” attribute, except under certain conditions. For details, consult guidance on providing text alternatives for images.

Two errors, two real problems: a srcset with width descriptors and no sizes, and an img without alt. The checker doesn't catch alt="image", because it is syntactically valid; bad alt text is found only by human eyes or content review. Making the alt field required for images coming from the CMS (Content Management System) and asking editors "what does the image show?" covers the half the checker can't see.

srcset is not an instruction, it is a list of suggestions. In my first attempt, two images on the same page shared the same files, and Chromium used 400w instead of 200w on a 1x screen because a larger candidate was already in the cache; WebKit picked 200w. That is why I made the measurements in this post with a new browser context for each case. In your own tests, ask "which file gets downloaded" with a clean cache too.

On the ASP.NET Core side: writing width/height and srcset by hand in Razor is error-prone. If the image metadata (width, height, generated sizes) comes from the CMS or from a manifest generated at build time, a Tag Helper or a partial view can produce these attributes from a single place. Writing Tag Helpers is covered in the ASP.NET Core MVC (Model-View-Controller) and Razor Pages series; in Angular, the NgOptimizedImage directive does much of the same job.

Common Pitfalls

First, putting loading="lazy" on the LCP image. As we measured, it delayed the request by half a second. If you made lazy the default of a component, you must be able to turn it off for the image at the top of the page.

Second, writing "picture", "image" or the file name in alt. Write what the image shows or (inside a link) what it does; if it is decorative, alt="".

Third, removing width/height because "it breaks the responsive design". It doesn't; with height: auto in CSS, it only declares the ratio.

Fourth, using width descriptors without sizes. The browser assumes 100vw and downloads the largest file for a small image.

Fifth, thinking picture falls back on a missing file. It only falls back on format support.

Sixth, giving an iframe no title, and publishing a video without controls or subtitles.

A Small Experiment

★☆☆ Add width="400" height="600" to the first image in cls.html and run cls.js again. Compare the position of the price line and the layout shift score. Then delete height: auto from the CSS and look at how the image stretches.

★★☆ Change sizes in responsive.html to (min-width: 1000px) 300px, 100vw and run responsive.js. Work out which file is chosen on the 390-pixel phone and why. Then reorder the source elements in the picture to put AVIF first.

★★★ On one of the product or content pages in your project, find the largest image on the first screen (the LCP marker in Chrome DevTools' Performance panel does the job). Check its loading, width/height, srcset/sizes and alt; then check whether the images and iframes at the bottom of the page are lazy. Verify what you find with a script like media.js, by looking at the request list on first load.

Images and media filled the body of the page. In the next post we move to the part of the page that isn't visible but changes the result on every share, every search result and every mobile device: the head. charset, viewport, title, description, Open Graph, theme-color and a short map of link rel values.