For years I watched the same mistake ship to production: a single 2400-pixel hero image served to every visitor, from a 4K desktop on fiber to a budget Android on a congested 3G connection. The result is predictable at the edge — wasted bandwidth, slow Largest Contentful Paint, and a cache that can only deliver one inefficient variant. Responsive images fix this at the HTML level before any CDN optimization kicks in. By giving the browser a set of image candidates and enough layout context to choose correctly, we let the client pick the smallest file that will still look sharp, and we let the edge cache serve each variant efficiently. In my experience, getting srcset and sizes right does more for image performance than any amount of post-processing alone.
This article walks through how responsive images actually work in the browser, how to write them correctly for real layouts, and where art direction with the <picture> element fits in. You don't need to be an image expert to use this — you just need to understand what information the browser has at parse time and how to give it better hints.
Why One Image File Fails Every Device on an Edge Network
When a page requests /images/hero.jpg with no variants, the CDN has only one object to cache and serve. That object must be large enough for the biggest viewport, otherwise it will look soft on high-density displays. But that same file is then forced down to a 360px wide phone that would have been perfectly served by a file one-fifth the size. From an edge delivery perspective, you are paying for egress on bytes that never needed to leave the PoP.
The Real Cost of a Single 2400px File
I've audited product pages where a 2400w JPEG hero weighed 680 KB after compression. On a Moto G with a 360px CSS viewport and a devicePixelRatio of 2, the browser still needs an image that is roughly 720 physical pixels wide. Serving the 2400w source means sending about 9x more pixels than necessary. At scale, that translates to terabytes of avoidable origin fetch and edge egress per month. More importantly, that image blocks rendering. The browser cannot show meaningful content until that hero is downloaded, decoded, and painted.
A better model is to generate 4 to 6 widths at build time or at the edge — typically something like 400, 800, 1200, 1600, and 2400 — and let the client select. The CDN can then cache each width as a separate object, often using URL-based transformation parameters, and serve each with a long-lived cache directive. The hit ratio improves because each variant is independently cacheable, and total bytes transferred drops by 60-80% on mobile without any visible quality loss.
What the Browser Knows Before CSS Loads
Here is the detail that makes responsive images non-intuitive: when the HTML parser encounters an <img>, it must decide which source to fetch immediately, before external CSS has been parsed, before layout is calculated, and before JavaScript runs. It cannot wait to see what width the image will actually be in the final computed layout. The sizes attribute exists precisely to bridge that gap. It tells the browser, in CSS-like syntax, how wide the image slot will be at different viewport widths, so it can choose a candidate from srcset right away. Without it, the browser assumes a fallback that is almost always wrong for your design.
Decoding srcset with w-Descriptors: Resolution Switching for Density
The srcset attribute provides the browser with a comma-separated list of image candidates. Each candidate is a URL followed by a descriptor that explains the image's intrinsic properties. For responsive layout purposes, you want w descriptors, which describe the image's pixel width.
An x descriptor — like 1x, 2x — describes pixel density. It is useful for fixed-size images such as icons that are always 48 CSS pixels wide, where you just want a sharper version for Retina screens. For fluid images that change width across breakpoints, w descriptors combined with sizes are almost always the right tool. I've found that mixing w and x descriptors in the same srcset is invalid and will cause the entire attribute to be ignored in some browsers, so pick one approach per element.
How the w-Descriptor and sizes Pair Works
A candidate like hero-800.jpg 800w means "this file is 800 pixels wide." The browser does not yet know how many CSS pixels the image will occupy on screen. It uses sizes to estimate the slot size, multiplies that by the devicePixelRatio, and then picks the smallest candidate that is equal to or larger than the required physical pixels. For example, on a 2x display where the slot is estimated at 400 CSS pixels, the browser needs ~800 physical pixels and will select the 800w candidate. On a 1x desktop where the same slot is 1200 CSS pixels, it will need 1200w and will pick a larger file.
This selection happens per navigation and is cache-aware. If the user rotates a tablet or moves a window, the browser will not automatically re-fetch a larger image for existing elements — it chooses once at parse time. That is why providing accurate sizes is more important than providing dozens of candidates.
| Approach | Descriptor Used | Requires sizes? | Best For | Limitation |
|---|---|---|---|---|
| w-descriptor srcset | 800w | Yes | Fluid/responsive images that change width with viewport | Requires accurate sizes; browser decides |
| x-descriptor srcset | 2x | No | Fixed-width images (icons, thumbnails) | Cannot adapt to layout width changes |
| No srcset (single src) | None | No | Simple fallbacks, very small decorative images | Same bytes for every device; no adaptation |
A Common Caching Mistake with srcset
Because srcset builds URLs like hero-800.jpg and hero-1200.jpg, each is a separate cache key at the edge. That is exactly what you want. The mistake I see is generating srcset URLs that include fingerprints or signed parameters that change per deploy without updating the HTML, which leads to cache misses. Keep the transformation logic deterministic. If you use an image CDN that generates variants via query strings like ?width=800, ensure those parameters are part of the cache key and that you normalize them — width=800&quality=75 and quality=75&width=800 should not be treated as different objects.
Writing the sizes Attribute So the Browser Can Reserve Layout Before CSS Loads
If srcset is the menu, sizes is the order. It is a list of media conditions and slot sizes, evaluated left-to-right, with the last entry being the default. The syntax looks like CSS media queries, but it is not CSS — it is parsed much earlier and only supports a subset of conditions (min-width, max-width). The browser evaluates sizes against the viewport width, finds the first matching condition, and uses that slot size to pick a candidate.
For example, sizes="(max-width: 768px) 100vw, 50vw" tells the browser: if the viewport is 768px or narrower, the image will be 100% of the viewport width; otherwise, it will be 50% of the viewport width. On a 375px iPhone, the estimated slot is 375 CSS pixels; on a 1440px desktop, it is 720 CSS pixels. The browser then factors in DPR to choose the file.
Mapping sizes to Actual Layout Breakpoints
In my experience, the most reliable way to write sizes is to look at your CSS layout, not your device breakpoints. Open dev tools, measure the rendered image width at each breakpoint, and transcribe it. A common two-column layout might be:
<img
src="hero-800.jpg"
srcset="hero-400.jpg 400w,
hero-800.jpg 800w,
hero-1200.jpg 1200w,
hero-2000.jpg 2000w"
sizes="(max-width: 640px) 100vw,
(max-width: 1024px) 50vw,
33vw"
alt="Mountain cabin in winter light"
width="1200" height="800"
loading="lazy" decoding="async">
This says: on phones the image is full-width, on tablets it occupies half the viewport, on desktop it is a third. Notice the width and height attributes — they are still critical because they allow the browser to calculate aspect ratio and reserve space before the image loads, preventing Cumulative Layout Shift. Responsive selection and layout stability are complementary, not interchangeable.
When sizes Is Wrong, Everything Is Wrong
If sizes overestimates the slot, the browser fetches an image that is larger than needed. If it underestimates, the browser fetches an image that will look blurry when stretched. Both are common. I have seen sizes="100vw" left as a copy-paste default on images that are actually constrained to a 640px container on desktop, causing 2000w fetches where 800w would have sufficed. Another frequent error is omitting sizes entirely. Per the spec, if you use w descriptors without sizes, the browser defaults to 100vw, which is almost never correct for grid or card layouts. As documented in web.dev Articles on responsive images, this default behavior is intentional for fallback but should not be relied on.
To validate, use Chrome DevTools' Network panel: filter by Img, reload with a cleared cache, and inspect the chosen resource alongside the rendered size. If a 320px wide card is fetching a 1600w candidate on a 1x display, your sizes is lying to the browser.
When to Reach for the Picture Element and Art Direction
Most responsive image problems are resolution switching problems — the same composition, just different sizes. srcset and sizes handle that efficiently. Art direction is different: you want to serve a fundamentally different crop, aspect ratio, or even image at different breakpoints. A wide landscape hero that works on desktop might be illegible as a tiny strip on mobile, where a tight vertical crop of the subject would perform better.
That is where <picture> comes in. It is a wrapper that contains zero or more <source> elements and one <img> for fallback. The browser evaluates each <source> in order and uses the first one whose media condition matches and whose type the browser supports. Unlike srcset alone, which leaves the decision to the browser's algorithm, <picture> gives you explicit control.
Resolution Switching vs. Art Direction in Practice
Use this decision rule: if the image content stays identical and only its dimensions change, use img srcset + sizes. If the image framing, aspect ratio, or focal point needs to change, use picture. In my experience, overusing picture for simple resizing adds markup complexity and edge cache variance without benefit. Save it for heroes, editorial leads, and marketing banners where composition matters.
<picture>
<!-- Art-directed crop for narrow viewports -->
<source
media="(max-width: 640px)"
srcset="hero-crop-square-400.jpg 400w,
hero-crop-square-800.jpg 800w"
sizes="100vw" >
<!-- Default wide crop for larger viewports -->
<source
srcset="hero-wide-800.jpg 800w,
hero-wide-1200.jpg 1200w,
hero-wide-2000.jpg 2000w"
sizes="(max-width: 1024px) 100vw, 65vw" >
<img
src="hero-wide-1200.jpg"
alt="Mountain cabin with snow-covered roof"
width="1600" height="900"
loading="eager" decoding="async">
</picture>
Note that each <source> can have its own srcset and sizes. The mobile source here assumes the cropped image is always full-width on small screens, while the desktop source accounts for a layout where the hero only occupies 65% of the viewport. The final <img> provides the fallback src and alt text, and its width and height should match the default source to preserve layout.
Using Picture for Format Fallback, Not Just Media Queries
<picture> also lets you specify type for modern formats. However, if you are behind an image CDN, you often don't need to handle format negotiation in markup at all. An edge that supports automatic format selection can serve AVIF to browsers that accept it, WebP where appropriate, and fall back to JPEG otherwise — all from a single URL — based on the Accept header. This keeps markup simpler and lets you adjust format strategy without redeploying HTML. For more on that edge-side approach, see Image CDN Format Negotiation: Serving AVIF, WebP and JPEG XL by Browser. When I control the edge, I prefer letting the CDN handle format selection and reserving <picture> strictly for art direction.
How srcset, Client Hints, and CDN Image Transformation Work Together
In a modern delivery setup, responsive images are a collaboration between markup and edge logic. srcset lists the logical widths you need, sizes tells the browser which one to request, and the CDN materializes that width efficiently from a single high-resolution origin master. You don't need to generate and upload every variant ahead of time; you can transform on the fly and cache at the edge.
A typical pattern is to use a URL scheme that encodes width, quality, and format implicitly. The origin only stores the master, and the CDN fetches, resizes, compresses, and caches each requested width at the PoP closest to the user.
<!-- The HTML references logical widths; the CDN handles the transformation -->
<img
src="/cdn-cgi/image/width=1200,quality=75/images/hero.jpg"
srcset="/cdn-cgi/image/width=400,quality=75/images/hero.jpg 400w,
/cdn-cgi/image/width=800,quality=75/images/hero.jpg 800w,
/cdn-cgi/image/width=1200,quality=75/images/hero.jpg 1200w,
/cdn-cgi/image/width=2000,quality=75/images/hero.jpg 2000w"
sizes="(max-width: 640px) 100vw, (max-width: 1200px) 50vw, 800px"
alt="Forest trail at golden hour"
width="1200" height="675">
This markup is cache-friendly because each width is a distinct URL, yet maintainable because you can change quality or add a new width without touching the origin asset. The edge can also apply perceptual compression settings centrally. For tuning quality vs. bytes, I rely on objective metrics rather than guesswork, as described in Image Compression Quality: SSIM, PSNR and Perceptual Metrics for CDN Optimization.
Where Client Hints Fit and When to Avoid Them
Client Hints (Sec-CH-DPR, Sec-CH-Viewport-Width, Sec-CH-Width) offer an alternative where the browser sends display characteristics as request headers and the server selects the image. In theory, this removes the need for srcset. In practice, I've found Client Hints add significant complexity: they require opt-in via Accept-CH or Permissions-Policy, they introduce Vary header cache fragmentation, and they don't work for the very first navigation before the hint is negotiated. They also require that your CDN correctly varies the cache by those headers, or you risk serving a 400w image from cache to a 2x desktop.
For most teams, a robust srcset + sizes implementation plus edge resizing is simpler and more predictable than a Client Hints-based architecture. Client Hints make sense in controlled, app-like environments where you own the entire delivery path and can guarantee correct Vary handling at every cache layer, as specified by the HTTP Working Group Specifications for content negotiation and caching. Otherwise, keep the decision in the browser where it belongs.
Preloading and Priority for the Critical Hero
For the Largest Contentful Paint image, add a <link rel="preload"> with imagesrcset and imagesizes so the browser can start the fetch in the head, before it discovers the <img>. This is one of the few cases where a preload helps rather than hurts. But only preload one hero — preloading multiple responsive images wastes bandwidth and can contend with more critical resources.
Diagnosing Responsive Image Failures in Production: Caching, Layout Shift, and Bandwidth
When responsive images are misconfigured, the symptoms show up in your Real User Monitoring long before they show up in synthetic tests. Look for three signals: high image bytes on mobile, layout shift attributed to images, and low edge cache hit ratios for image URLs.
Eliminating Layout Shift with Width, Height, and Aspect Ratio
Responsive selection does not prevent Cumulative Layout Shift. CLS happens when the browser does not know how much space to reserve before the image loads. Always include width and height attributes that reflect the intrinsic aspect ratio of your source, and in CSS add height: auto; max-width: 100%. Modern browsers calculate the aspect ratio from those attributes even when the image is fluid. In my experience, adding correct dimensions fixes more CLS issues than any JavaScript placeholder technique, and it costs nothing at the edge.
If you use art-directed crops with different aspect ratios per breakpoint, ensure your CSS preserves the ratio per <source> or use the aspect-ratio property in the container query context. Otherwise the fallback width/height will reserve space for the wrong shape and shift when the art-directed source loads.
Cache Vary, Transform Parameters, and Hit Ratio
If your edge transforms images based on Accept headers for format negotiation, you must include Vary: Accept on the response — but only for those transformed responses, not for your HTML. Overuse of Vary: * or adding Vary: Sec-CH-DPR globally will fragment your cache and reduce hit ratios. I've seen image caches drop from 96% to 71% after enabling Client Hints incorrectly. With srcset-based delivery, each width is already a unique URL, so you typically don't need to vary on DPR at all; the browser's choice handles it.
Measuring Waste and Loading Strategy
Combine srcset with smart loading. Responsive selection determines which bytes to fetch; lazy loading determines when. For below-the-fold images, add loading="lazy" so the browser defers the fetch until the image nears the viewport. For long galleries, an Intersection Observer-based approach can give you finer control over preload distance and CDN request coalescing. To implement that efficiently without harming crawlability, see Lazy Loading with Intersection Observer: Reducing CDN Bandwidth with Smart Loading.
Finally, measure the right metric: not just total image bytes, but bytes per page by viewport class. In your analytics, segment image transfer size by max-width buckets. If mobile users are downloading desktop-sized bytes despite correct srcset, inspect the actual requested URLs at the edge. Often the HTML is correct but a service worker or a rewrite rule is stripping the srcset width parameters and collapsing all requests to the fallback src.
Frequently Asked Questions
Do I need to create every srcset variant manually before deployment?
No. You can generate variants at build time using tools like Sharp or ImageMagick, or generate them on demand at the edge with an image CDN. In the on-demand model, you store only the high-resolution master at the origin and configure URL parameters like width=800 to let the edge create, optimize, and cache each width on first request. Both approaches work — build-time gives you full control over output quality, edge transformation gives you flexibility to add widths without redeploying.
What happens if I use srcset without a sizes attribute?
The browser falls back to sizes="100vw". That means on a 1440px desktop, the browser will assume the image slot is 1440 CSS pixels wide, even if your layout constrains it to 400px. It will then request a much larger file than needed, wasting bandwidth. Always pair w descriptors with an accurate sizes that reflects your actual layout breakpoints.
Should I use picture for every responsive image?
No. Use <img srcset sizes> for resolution switching where the same composition is served at different sizes — this covers 90% of cases. Reserve <picture> for art direction where you need a different crop or aspect ratio per breakpoint, or when you explicitly need format-specific sources and your edge does not handle automatic format negotiation.
Does srcset help with bandwidth if my images are cached by a CDN?
Yes. srcset reduces origin-to-edge and edge-to-client transfer. Even with a high cache hit ratio, a mobile client fetching a 2400w cached object still pays for 2400w of bytes over the last mile. By letting mobile clients fetch an 800w cached variant instead, you cut last-mile bytes and improve load time, even though the CDN already had the larger object cached.