Responsive Images: srcset, sizes, and picture
Images account for most of the bytes on the average webpage. And most sites serve the same giant image to a 4K monitor and a budget Android phone. That's insane when you think about it.
The phone downloads 3MB of image data it'll never display. The user waits longer. The battery drains faster. Everyone loses except your hosting bill.
HTML has had solutions for this for years. Most developers don't use them properly. Let's fix that.
The Problem with Basic <img>
<img src="hero.jpg" alt="Hero image">
Simple. Familiar. And problematic.
This serves the same image file regardless of:
- Screen size (2000px desktop vs 375px phone)
- Pixel density (1x laptop vs 3x iPhone)
- Network speed (fiber vs sketchy mobile data)
- Browser support (webp vs jpeg)
You're either serving way too much data to small screens or way too little resolution to high-density displays. Usually both, depending on the image.
srcset: Resolution Switching
The srcset attribute lets you provide multiple image sources. The browser picks the best one.
<img src="hero-800.jpg" srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1200.jpg 1200w, hero-1600.jpg 1600w" sizes="100vw" alt="Hero image">
What's happening here:
src is the fallback for browsers that don't support srcset (basically none anymore, but it's still the default).
srcset lists available images with their widths. hero-400.jpg 400w means "this image is 400 pixels wide."
sizes tells the browser how big the image will display. 100vw means full viewport width.
The browser does math: if viewport is 800px wide and pixel density is 2x, it needs a 1600px image. It picks hero-1600.jpg.
Understanding the sizes Attribute
This is where people get confused. sizes doesn't control how the image displays - CSS does that. sizes tells the browser how big the image will be so it can make a smart choice.
It supports media queries:
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 33vw"
Translation: On screens up to 600px, image fills the viewport. From 601-1200px, it's half the viewport. Above 1200px, it's a third.
Match this to how your CSS actually sizes the image. If they don't match, the browser might choose wrong.
The picture Element: Art Direction
Sometimes you don't just want different sizes. You want different crops or completely different images.
Classic example: a wide landscape photo that looks great on desktop but becomes an unrecognizable mess on mobile. You want to crop tighter for small screens.
That's what <picture> is for:
<picture>
<source media="(max-width: 600px)" srcset="hero-mobile.jpg">
<source media="(max-width: 1200px)" srcset="hero-tablet.jpg">
<img src="hero-desktop.jpg" alt="Hero image">
</picture>
The browser checks each <source> in order. First one with matching media query wins. The <img> at the end is fallback.
You can combine this with srcset for both art direction AND resolution switching:
<picture>
<source media="(max-width: 600px)" srcset="hero-mobile-400.jpg 400w, hero-mobile-800.jpg 800w">
<source srcset="hero-desktop-800.jpg 800w, hero-desktop-1600.jpg 1600w">
<img src="hero-desktop-800.jpg" alt="Hero image">
</picture>
Format Switching with picture
<picture> also handles format fallbacks. WebP is smaller than JPEG, AVIF is even smaller, but not all browsers support them.
<picture>
<source type="image/avif" srcset="hero.avif">
<source type="image/webp" srcset="hero.webp">
<img src="hero.jpg" alt="Hero image">
</picture>
Browser checks if it supports AVIF. If yes, uses it. If not, checks WebP. If not, falls back to JPEG.
Savings can be significant: 25-50% smaller files with modern formats at same quality.
Lazy Loading
Images below the fold shouldn't load until users scroll to them. Native lazy loading is now widely supported:
<img src="photo.jpg" loading="lazy" alt="A photo">
That's it. One attribute. The browser handles the rest - loads images when they're close to entering the viewport.
Don't lazy load above-the-fold images. It delays their loading. Only apply to images users won't see immediately.
Aspect Ratio and Layout Shift
Responsive images can cause layout shift if dimensions aren't specified. Image loads, takes up space, pushes content down. Users hate this.
Solution: always specify width and height:
<img src="photo.jpg" width="800" height="600" alt="Photo">
CSS can still make it responsive:
img { max-width: 100%; height: auto; }
The browser calculates aspect ratio from width/height and reserves space before the image loads. No shift.
Or use the newer aspect-ratio property in CSS:
img { aspect-ratio: 4 / 3; width: 100%; }
Generating Responsive Images
You need multiple versions of each image. Doing this manually is tedious. Automate it.
Build-time tools: Sharp (Node.js), ImageMagick, or frameworks like Next.js/Gatsby have built-in image processing.
CDNs: Services like Cloudinary, Imgix, or Cloudflare Images resize on-the-fly. Upload one master image, request any size via URL parameters.
CDN approach example:
<img src="https://cdn.example.com/hero.jpg?w=800" srcset="https://cdn.example.com/hero.jpg?w=400 400w, https://cdn.example.com/hero.jpg?w=800 800w, https://cdn.example.com/hero.jpg?w=1600 1600w" sizes="100vw" alt="Hero">
Easier than maintaining multiple files, and CDNs often handle format conversion too.
What Sizes to Generate
Common breakpoints: 320, 640, 768, 1024, 1366, 1600, 1920, 2560
But you don't need every size for every image. Consider:
- What's the largest this image will ever display?
- What's the smallest?
- How much does file size change between sizes?
A thumbnail that never exceeds 200px doesn't need a 1920px version. A hero that fills the screen on desktop needs the full range.
Start with these for most cases: 400, 800, 1200, 1600. Add more if you see gaps in coverage.
Testing Responsive Images
Chrome DevTools → Network tab → filter by "Img" → check what actually loads at different viewport sizes.
Resize your browser and reload. Did the right image load? Is file size reasonable?
Also check:
- Images are sharp on high-density displays
- Images aren't oversized on low-density displays
- Layout doesn't shift as images load
- Lazy loading is working for below-fold images
Performance Budget
Set targets for image bytes per page. Reasonable starting points:
- Hero image: under 200KB
- Content images: under 100KB each
- Thumbnails: under 20KB each
- Total images per page: under 1MB
These vary by site type, but having any budget beats having none. Measure, set limits, stick to them.
Common Mistakes
Forgetting sizes. srcset without sizes assumes 100vw. If your image is actually 50% viewport width, the browser will download images twice as big as needed.
Wrong aspect ratios in srcset. All images in a srcset should have the same aspect ratio. If they don't, switching between them causes layout shift or distortion.
Not testing on real devices. Simulators don't always match real phone behavior. Test on actual phones with actual slow connections.
Lazy loading everything. Above-fold images should load immediately. Lazy loading them delays your largest contentful paint (LCP).
Ignoring format support. WebP is nearly universal now. Not using it means unnecessarily large files. AVIF support is growing - consider it too.
Quick Implementation Checklist
- Every image has srcset with multiple sizes?
- sizes attribute matches actual display size?
- Modern formats (WebP/AVIF) offered with fallbacks?
- Lazy loading on below-fold images?
- Width and height specified to prevent layout shift?
- Images optimized/compressed appropriately?
- Tested on real mobile devices?
Responsive images aren't glamorous. Nobody will consciously notice that your images are properly sized. But they'll feel the faster load times. They'll appreciate pages that don't shift. They'll stay longer because the experience is better.
Do the work. Your users, and their data plans - will thank you.