Images and media
Sending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
Why images need their own plan
Picture a shoe shop. The designer exports the hero photo of this season's running shoe at 4000 by 2667 pixels, saves it as a high-quality JPEG of 2.4 MB, and a developer drops it into the page with a plain img tag. On the designer's 27-inch screen it looks perfect.
Now open it on a mid-range phone on 4G, 412 CSS pixels wide. Even at a device pixel ratio of 2.625 it shows only about 1080 pixels across. The phone downloads 2.4 MB, decodes about 43 MB of pixels, and throws away over 90 percent of them to fit.
While all of that happens, three things go wrong at once. The text under the photo jumps down when the image finally gets its height. The largest thing on screen, the hero, appears seconds late. And the user pays for megabytes of mobile data they never see.
Three failures, three metrics
Each of those failures maps to a number that Google and your own monitoring track. This is why images get their own plan in a frontend system design: they touch more user-facing metrics than any other kind of file.
- Too many bytes. The image is bigger than the screen needs, or in an old format. This slows LCP, Largest Contentful Paint, the time until the biggest element on the first screen is drawn, which is usually an image.
- No reserved space. The browser does not know the image's height until the file arrives, so it lays out a zero-height box and moves everything later. That movement is CLS, Cumulative Layout Shift.
- Wrong timing. Images far below the fold compete with the hero for bandwidth, or the hero is lazy-loaded by mistake and starts late.
How big the problem is
The HTTP Archive's Web Almanac 2025 measured about 911 KB of images on the median mobile home page. Per byte, images cost less than JavaScript, because decoding mostly happens off the main thread. But there are a lot of them, and they sit right on the path to LCP.
The good news: image work is mostly mechanical. Five moves fix most pages: resize, pick a modern format, reserve space, load in the right order, and show something while waiting. This lesson covers those five in depth, plus video.
Where this lesson fits
Core Web Vitals explains how LCP and CLS are measured. Loading strategies covers fetchpriority, preload, and loading="lazy" for every resource. Estimation for frontend has the formula for decoded image memory. This lesson focuses on the image itself: which file, which size, which format, and how it arrives.
Check your understanding · Exercise 1
A product page shows a 2.4 MB hero JPEG with no width or height. Which problems does that cause on a phone? Select all that apply.
The mental model: measure the frame, then order the print
Think of a picture frame on a wall. You would not order a poster-sized print for a postcard frame. You measure the frame, order the print that fills it, and hang the empty frame first, so nothing on the wall moves when the print arrives.
That is the whole idea of image optimization. The frame is the box the image takes in the layout. The print is the file the browser downloads. Your job is to make the print match the frame, in the cheapest material that still looks right, and to hang the frame early.
Four questions for every image
Every image on a page answers four questions, whether you answer them on purpose or not. A plain img with one large JPEG answers all four badly: one size for every screen, an old format, unknown space, and default timing.
| Question | The tool | Who decides |
|---|---|---|
| How many pixels? | srcset with width descriptors, plus sizes | You list the candidates; the browser picks one |
| Which format? | picture with type, or the CDN reading the Accept header | You or the image CDN |
| How much space? | width and height attributes, or CSS aspect-ratio | You, in the markup or CSS |
| When does it load, and what shows meanwhile? | fetchpriority, loading, a placeholder | You, based on where it sits on the page |
The pipeline from upload to pixels
It also helps to see where each decision lives. An image passes several stops before it reaches the screen.
- The original is stored once, at full size. Nobody downloads it directly.
- An image CDN or build step makes variants: several widths and formats, each with its own cached URL.
- The HTML describes the variants: URLs with widths, the layout size, the aspect ratio, and the priority.
- The browser adds what only it knows (viewport, DPR, supported formats) and requests exactly one variant.
- Decode and paint. The file becomes a bitmap of raw pixels, drawn into the box reserved for it.
srcset.Notice who does the hard work. You never ask JavaScript to measure the screen and choose a URL: that runs too late and hides the image from the browser's preload scanner. You describe the options in HTML and let the browser choose while it is still parsing.
Responsive images: srcset and sizes
The first question is how many pixels to send. The answer depends on two things the server cannot know: how wide the image's box will be, and how many device pixels the screen packs into each CSS pixel. HTML lets you describe several files and lets the browser choose.
CSS pixels and device pixels
Layout uses CSS pixels. A phone whose layout is 412 CSS pixels wide may have a screen that is 1080 physical pixels wide. The ratio between the two is the device pixel ratio, or DPR, which JavaScript can read as window.devicePixelRatio.
On that phone the DPR is 2.625. Most laptops are 1 or 2, most phones 2 to 3.
An image looks sharp with at least one image pixel per device pixel. So a 400 CSS pixel box on a DPR 2 screen wants an 800 pixel file. On a DPR 1 screen that file wastes three quarters of its pixels, because pixel count grows with the square of the width.
Fixed-size images: density descriptors
If an image is always drawn at the same CSS size, such as a 120 pixel logo or a 48 pixel avatar, list one file per density with an x descriptor. The browser picks the one that matches the screen's DPR.
<img
src="/logo-120.png"
srcset="/logo-120.png 1x, /logo-240.png 2x, /logo-360.png 3x"
width="120" height="40"
alt="Stride Shoes">
Fluid images: width descriptors and sizes
Most content images are fluid: full width on a phone, half width on a tablet, a fixed column on a desktop. For these, describe each file by its real pixel width with a w descriptor, and tell the browser how wide the box will be with sizes.
<img
src="/shoe-800.jpg"
srcset="/shoe-480.jpg 480w, /shoe-800.jpg 800w,
/shoe-1200.jpg 1200w, /shoe-1600.jpg 1600w,
/shoe-2400.jpg 2400w"
sizes="(min-width: 1200px) 1200px, 100vw"
width="1200" height="800"
alt="Blue running shoe, side view">
sizes is a list of media conditions, each followed by a length, read left to right. The first condition that matches wins, and the last entry has no condition: it is the fallback. Lengths can be px, vw, em, or calc(), but not percentages, because the browser has not done layout yet and has nothing to take a percentage of.
Why say the width at all? Because the browser picks the image while reading raw HTML, before CSS and layout. sizes is your promise about a layout it has not computed yet.
The selection math, step by step
- Find the slot width. Evaluate
sizesagainst the viewport. On a 412 pixel phone,(min-width: 1200px)is false, so the slot is100vw, which is 412 CSS pixels. - Find the target. Multiply by the DPR: 412 times 2.625 is about 1082 device pixels.
- Compute each candidate's density. Divide the candidate's width by the slot width: 800 / 412 is 1.94x, 1200 / 412 is 2.91x.
- Pick the smallest candidate that is dense enough. 800w gives 1.94x, which is below 2.625, so it would look a little soft. 1200w gives 2.91x, the first one at or above the DPR. The browser fetches
shoe-1200.jpg.
sizes. Density is candidate width divided by slot width, and the first candidate that reaches the DPR wins.The same markup gives different answers elsewhere. A 1440 pixel laptop at DPR 1 matches the first condition, so the slot is 1200 pixels and the target is 1200: it fetches 1200w. The same laptop at DPR 2 wants 2400 device pixels and fetches 2400w. A 360 pixel phone at DPR 3 wants 1080 and fetches 1200w.
This is a model, not a law: the spec lets the browser pick any candidate. Current Chrome follows it closely, but reuses a larger candidate already in its memory cache instead of fetching a smaller one. Browsers also tend to keep the larger file when the window shrinks, and the spec allows picking smaller on a slow connection or in data-saving mode. Try the model in the lab below.
Slot 412 CSS px, target 1082 device px, picked 1200w (about 94 KB).
Choosing the candidate widths
Too few widths, and some screens get a file far bigger than they need. Too many, and the CDN cache splits across variants that each get fewer hits. A practical rule: 4 to 6 widths from about 320 to 2400, each step roughly 20 to 30 percent more bytes than the last.
Many teams also cap large photos at 2x. A 3x file has 2.25 times the pixels of a 2x file, and at arm's length most people cannot tell them apart. You cap by leaving the biggest widths out of srcset.
sizes="auto" for lazy images
For lazy images there is a newer shortcut: sizes="auto". By the time a lazy image loads, layout is done, so the browser can measure the real box. It only applies with loading="lazy", and the image still needs width and height so the box has a size before it loads.
Chrome has supported it since version 126 (2024), Firefox since 150, and Safari since 27 (September 2026). Older browsers skip the auto entry, so write it first and keep your usual value after it as a fallback: sizes="auto, (min-width: 1024px) 25vw, 50vw".
Check your understanding · Exercise 2
Candidates are 400w, 800w, 1200w, 1600w. sizes is (min-width: 900px) 33vw, 100vw. A laptop has a 1500 pixel wide viewport at DPR 2. Which file does the model pick? Pick one.
The picture element: art direction and format fallback
srcset assumes every candidate is the same picture at a different resolution, and lets the browser choose freely. When you need a different crop on a phone, or a new format with a safe fallback, use picture.
How picture works
A picture holds zero or more source elements and exactly one img. The browser walks the sources from top to bottom and takes the first one whose media matches and whose type it supports. If none match, it uses the img. The chosen source only supplies the URL list: the img is still the element that is laid out, styled, and read by screen readers, so alt, width, height, loading, and fetchpriority all go on the img.
<picture>
<source type="image/avif"
srcset="/shoe-800.avif 800w, /shoe-1600.avif 1600w"
sizes="(min-width: 1200px) 1200px, 100vw">
<source type="image/webp"
srcset="/shoe-800.webp 800w, /shoe-1600.webp 1600w"
sizes="(min-width: 1200px) 1200px, 100vw">
<img src="/shoe-800.jpg"
srcset="/shoe-800.jpg 800w, /shoe-1600.jpg 1600w"
sizes="(min-width: 1200px) 1200px, 100vw"
width="1200" height="800" alt="Blue running shoe, side view">
</picture>
img is what gets laid out, styled, and read aloud, so alt, dimensions, loading, and fetchpriority all belong on it.Two different jobs
- Format fallback uses
type. Every source shows the same picture; they differ only in encoding. Order matters: put the best format first, because the first supported one wins even if a later one would be smaller. - Art direction uses
media. The sources show different pictures: a wide landscape banner on desktop, a tight square crop of the shoe on a phone where the landscape version would make the shoe tiny. Here you are overriding the browser, which is the point.
You can combine both, but sources multiply: two crops times three formats is six. An image CDN that picks the format from the request header (covered later) removes the format half.
<picture>
<source media="(max-width: 600px)"
srcset="/sale-square-600.avif 600w, /sale-square-1200.avif 1200w"
width="600" height="600">
<img src="/sale-wide-1600.avif"
srcset="/sale-wide-1600.avif 1600w, /sale-wide-2400.avif 2400w"
sizes="100vw" width="1600" height="600" alt="Autumn sale: 30 percent off trail shoes">
</picture>
When the crops have different aspect ratios, put width and height on each source as well as on the img. Chrome 90, Firefox 108, Safari 15, and later versions use them to reserve the right space for whichever source wins; without them, the phone reserves the banner's shape and then shifts.
Decision The hero is a wide banner. On phones the product inside it becomes tiny. How do you serve a better crop?
What we pick: For a few campaign banners, use picture with media and designer-made crops. For thousands of product or user photos, use CDN crops with a stored focal point. Use the CSS crop only when the hidden area is small.
Formats: AVIF, WebP, JPEG, and the rest
The second question is which format. Right-sizing removes pixels you do not need; a better format stores the rest in fewer bytes. The two savings multiply.
Lossy, lossless, raster, vector
- Lossy formats throw away detail the eye is unlikely to notice, and you choose how much with a quality setting. Right for photos.
- Lossless formats keep every pixel exactly. Right for screenshots, diagrams, and anything with sharp edges and flat color, where lossy artifacts show as smudges around text.
- Raster formats store a grid of pixels. Vector formats, which on the web means SVG, store shapes and scale to any size with no extra bytes. Logos and icons should almost always be SVG.
The formats you will actually use
| Format | Good for | Size versus JPEG | Browser support | Watch out for |
|---|---|---|---|---|
| JPEG | Photos, as the universal fallback | Baseline | Everywhere | No transparency; visible blocks at low quality |
| PNG | Screenshots, flat graphics, transparency | Much larger for photos | Everywhere | Never use it for photos |
| WebP | Photos and graphics, lossy or lossless, with transparency | Often 25 to 35 percent smaller | All current browsers | Gains over a well-tuned JPEG can be small |
| AVIF | Photos, especially at low and medium quality | Often around half | All current major browsers | Slow to encode; no progressive display |
| SVG | Logos, icons, simple illustrations | Tiny for shapes, useless for photos | Everywhere | User-uploaded SVG can carry scripts |
| GIF | Nothing new | Huge for animation | Everywhere | Replace animated GIFs with video |
Treat the size column with care. Savings depend on the photo, the encoder, and how you measure "the same quality". Before quoting a number, encode a sample of your own images and compare them at matched visual quality.
Why not AVIF for everything?
AVIF usually wins on bytes, but encoding is often many times slower than JPEG or WebP, which matters for large upload volumes or first-request encoding at a CDN. AVIF files, as usually encoded, also do not display progressively: a progressive JPEG paints a blurry full image early and sharpens it, while an AVIF appears only once enough of it has arrived.
For most sites the answer is still AVIF first, with WebP and JPEG as fallbacks. For very large heroes on slow networks, test a progressive JPEG against AVIF and pick by measured LCP, not file size.
JPEG XL is a newer format with strong compression, lossless modes, and progressive display. Safari has supported it since version 17, and Chrome 155 and Firefox 158 turned it on by default in October 2026. Many users still run older versions, so for now add it as an extra picture source or let the CDN negotiate it, rather than making it the default. Chrome's team suggests testing both AVIF and JPEG XL, with JPEG XL most useful for high-fidelity and lossless images.
Quality settings and metadata
- Quality numbers are not comparable across encoders. JPEG quality 80 and AVIF quality 80 mean different things. Tune each format separately, ideally against a perceptual metric such as SSIMULACRA 2 or Butteraugli, or let the CDN's automatic quality do it.
- Strip metadata. Camera files carry EXIF data: camera model, date, and often GPS coordinates. Removing it saves bytes and protects the privacy of users who upload photos of their homes.
- Apply orientation first. Phone photos are often stored sideways with an EXIF tag saying "rotate 90 degrees". If you strip the tag without rotating the pixels, the image shows sideways.
Decision How should the page get AVIF to browsers that support it and JPEG to the rest?
What we pick: With an image CDN, use content negotiation and keep the markup simple. Without one, use picture with AVIF and WebP sources and a JPEG img. Pick WebP-only for small sites where storage and build time matter more than the last 20 percent of bytes.
Check your understanding · Exercise 3
Which images are in the wrong format? Select all that apply.
Reserving space and showing something early
The third question is how much space the image takes. The browser lays out the page long before images arrive. If it does not know an image's height, it uses zero, then pushes the text below down when the file says how tall it is. That jump is a layout shift: the user aims for a link and taps an ad instead.
width and height give the aspect ratio
Put width and height attributes on every img, set to the intrinsic pixel size or any numbers with the same ratio. Browsers turn them into a default CSS aspect-ratio, so with a fluid width the height is known before a byte of the image arrives.
/* width and height attributes in the HTML: 1200 and 800 */
img {
max-width: 100%;
height: auto; /* keep the ratio when the width shrinks */
}
/* what the browser applies by default, in effect: */
/* aspect-ratio: auto 1200 / 800; */
height: auto, they only supply the ratio, so a 412 pixel wide box gets a height of about 275 pixels at once.The auto in that default matters: once the real image loads, its own ratio wins. So if the attributes are slightly wrong the box still settles on the true shape, with a small shift, instead of stretching the photo.
width and height with height: auto give the browser a ratio, so a 412 pixel wide box is about 275 pixels tall before any image byte arrives.When CSS should own the box
In grids and cards, the design often fixes the shape: every product tile is square, whatever the seller uploaded. Set aspect-ratio: 1 / 1 in CSS and object-fit: cover so the photo fills the box without stretching. CSS reserves the space, and the image's own ratio no longer matters.
For user uploads of any shape, store width and height when the image is uploaded and send them with the feed data, so the client can render the attributes for images it has never seen. Reading dimensions after the image loads is too late: the shift has already happened.
Placeholders: something to look at while bytes arrive
Reserving space stops the jump, but the user still stares at an empty box. A placeholder fills that box with a cheap hint of the image, so the page looks finished sooner and the real image fades in rather than popping. There are four common levels, from cheapest to richest.
| Placeholder | What it is | Cost | Needs JavaScript |
|---|---|---|---|
| Dominant color | One background color picked from the photo | A few bytes in CSS | No |
| LQIP | Low Quality Image Placeholder: a tiny version, about 16 to 40 pixels wide, inlined as a data URL and blurred by CSS | About 200 to 800 bytes per image in the HTML | No |
| BlurHash | A short text string, about 20 to 30 characters, that encodes a few color components | Tiny in the data, plus a small decoder | Yes, to draw it into a canvas or data URL |
| ThumbHash | Like BlurHash, about 25 bytes, but also keeps the aspect ratio, transparency, and a bit more detail | Tiny in the data, plus a small decoder | Yes |
The blur-up effect shows the LQIP scaled up with a CSS blur, then fades in the real image on top once it loads. BlurHash and ThumbHash do the same job with smaller payloads, which suits feeds and API responses where an inline image per item would bloat the JSON.
img.decode() before the fade, and skip the fade under reduced motion.Two traps. Inline LQIPs live in the HTML, so 40 of them add tens of kilobytes to the document; use them for the first screen and a color for the rest. And a hash placeholder needs its decoder JavaScript to run first, which can be later than a plain color would have shown.
Since Chrome 112, LCP ignores images with less than 0.05 bits of data per displayed pixel, such as a tiny blurred thumbnail stretched over a large box, so the real image still counts. A sharper or larger placeholder can cross that line and become the LCP element instead, which flatters your metric. Check the reported LCP element after adding one.
Decoding: decoding="async" and img.decode()
After download comes decoding: turning the file into a bitmap, which can take tens of milliseconds for a big photo on a slow phone. decoding="async" tells the browser it may paint the rest of the frame without waiting for this image; sync says wait, and auto, the default, lets the browser decide.
Browsers already decode most images off the main thread, so decoding="async" is a small, safe hint, not a big win. In JavaScript, await img.decode() resolves when an image is ready to paint. Use it before swapping a placeholder for the real image, so the swap does not flash.
Check your understanding · Exercise 4
A news feed shows user photos of any shape. The page jumps as photos load. Which fixes stop the jump? Select all that apply.
Image CDNs: variants on demand
Five widths times three formats is fifteen variants per image, plus crops. A build script can make them for a blog. A marketplace with forty million seller photos needs variants made on demand. That is what an image CDN does.
Transforms in the URL
An image CDN keeps the original once and treats the URL as a recipe. The path or query string says which original, what width, what format, what quality, and what crop. The first request for a recipe makes the variant; every later request for the same URL is a normal cache hit at the edge.
original: s3://photos/listing/8812/front.jpg (4032 x 3024, 3.9 MB)
recipe URL: https://img.example.com/listing/8812/front.jpg
?w=800 resize to 800 pixels wide
&fit=cover crop to fill the box
&ar=1:1 square
&fm=auto best format the browser accepts
&q=auto quality chosen per image
result: 800 x 800 AVIF, about 60 KB, cached at the edge
Your markup then builds srcset by changing only the w parameter, usually in a small helper that takes an image id and a list of widths.
w value is a new first request.Picking the format: Accept and Vary
With fm=auto, the CDN reads the Accept header the browser sends for images. Chrome, for example, has long sent image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8. If image/avif is listed, the CDN returns AVIF; if only WebP, it returns WebP; otherwise JPEG. The response carries Vary: Accept so any cache knows that the same URL has several versions.
Good image CDNs do not key their cache on the raw Accept string, which differs between browsers and would split the cache. They reduce it to a bucket (AVIF, WebP, or neither) and key on that. If you put your own CDN in front, check that it does the same, or it may serve a cached AVIF to an old browser.
Snap widths to a fixed list
If the frontend computes widths freely, from element.clientWidth for example, you get URLs for 411, 412, 413, and so on. Each is a separate cache entry that pays the transform cost, and the hit ratio collapses. Snap to a short fixed list and use it everywhere.
The same list is a security control. An open endpoint lets anyone request ?w=4999, ?w=4998, and so on, burning your transform budget. Reject widths not on the list, or sign URLs with an HMAC. And never let the endpoint fetch arbitrary remote URLs, or it becomes an open proxy; Next.js, for example, requires a list of allowed remote hosts.
Client hints, briefly
Chromium-based browsers can also send client hints, request headers such as Sec-CH-DPR and Sec-CH-Viewport-Width, after the server opts in with Accept-CH. Safari and Firefox do not send them, so treat them as an extra on top of srcset, never a replacement.
Decision When should the image variants be made?
What we pick: Use build time for sites whose images live in the repo, and on-demand edge transforms for user uploads at scale. Upload-time variants fit teams that must keep data in their own infrastructure. Whichever you pick, compute dimensions and placeholders at upload.
Video, posters, and replacing GIFs
Video follows the same four questions with much bigger files. A 30 second product video is several megabytes. Downloaded before the user decides to watch, it competes with the hero and the scripts for bandwidth, and costs real money on a metered plan.
preload: how much to fetch before play
The preload attribute on video is a hint about how much to download before the user presses play. Like other hints, browsers may override it, for example on cellular data or in data-saver mode.
| Value | What the browser fetches | Use it for |
|---|---|---|
none | Nothing until play, except the poster | Videos below the fold, or many videos on one page |
metadata | The first bytes: duration, dimensions, sometimes the first frame; usually tens to a few hundred KB | A video the user may well play; shows the duration in the controls |
auto | As much as the browser thinks is useful, possibly the whole file | Rarely; a page whose main purpose is that one video |
preload metadata: 230 KB before play. 150 KB wasted if the video is never played.
The poster is an image: optimize it like one
The poster attribute names an image to show before playback. With preload="none", the poster is all the user sees, and when the video is the biggest thing on the first screen, the poster is often the LCP element. So everything earlier in this lesson applies: the right size, a modern format, and reserved space.
One gap: poster takes a single URL, with no srcset. Serve one sized for the common layout through an image CDN, or overlay a real img with srcset and remove it on play. Give the video width and height too, so its box is reserved.
<video
controls
preload="none"
poster="https://img.example.com/demo-poster.jpg?w=1200&fm=auto"
width="1280" height="720">
<source src="/demo-720.webm" type="video/webm">
<source src="/demo-720.mp4" type="video/mp4">
<track kind="captions" src="/demo.en.vtt" srclang="en" label="English">
</video>
Replace animated GIFs with video
GIF dates from 1987. It allows 256 colors per frame and has no real motion compression, so animations are huge. A short screen recording is often several megabytes as a GIF and a small fraction of that as MP4 or WebM, because video codecs store mostly what changes between frames.
To make a video behave like a GIF, use autoplay, loop, muted, and playsinline. Browsers reliably allow autoplay only when the video is muted, and playsinline stops iPhones from opening it full screen. Animated AVIF or WebP inside a plain img is another option when you want image semantics, such as in user-generated content that only accepts images.
Check your understanding · Exercise 5
A recipe page has eight short how-to videos further down the page. Which setup keeps the first load light? Select all that apply.
Long videos: adaptive streaming
For long videos, streaming sites use adaptive bitrate streaming: the video is cut into segments of a few seconds, each encoded at several qualities, and the player picks the quality of each next segment from the measured connection. HLS and MPEG-DASH are the standard formats. Safari plays HLS natively, and recent Chrome versions have added native HLS too; elsewhere, and for DASH, a player such as hls.js or dash.js feeds segments through Media Source Extensions.
Failure modes, load order, and accessibility
Image bugs are quiet. Nothing throws an error when a phone downloads a 2400 pixel file for a 200 pixel slot; the page just gets slower. This chapter covers load order, what goes wrong in production, and accessibility.
Load order in one paragraph
The loading strategies lesson covers this in depth; the short version for images: the LCP image is a real img in the server-rendered HTML, never lazy, with fetchpriority="high". Images well below the fold get loading="lazy". Everything in between keeps the default.
fetchpriority and loading in depth.- CSS background images start late. The preload scanner reads HTML, not CSS, so a
background-imagehero is found only after the stylesheet is parsed. CSSimage-set()has nosizeseither. Prefer animgwithobject-fit: cover. - Images rendered by JavaScript start later still. They wait for the bundle, the API call, and the render. Server-render the hero, or preload it.
- Preloading a responsive image needs its own
srcset. Uselink rel="preload" as="image"withimagesrcsetandimagesizesthat match theimgexactly. A plainhrefto one width makes some screens download two files: the preloaded one and the onesrcsetactually picks.
Symptoms, causes, fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Grid thumbnails are hundreds of KB each | srcset with no sizes, so the browser assumes 100vw | Write an honest sizes, or sizes="auto" on lazy images |
| Images look soft on high density phones | sizes claims a smaller slot than CSS draws, or the largest candidate is too small | Fix sizes; check the rendered size against the file size in DevTools |
| The hero downloads twice | A preload href that does not match the candidate srcset picks | Preload with imagesrcset and imagesizes |
| Content jumps as images arrive | No width and height, or art-directed sources with different ratios and no dimensions | Add dimensions to the img and to each source |
| Uploaded photos appear sideways | EXIF orientation stripped without rotating pixels | Rotate first, then strip metadata |
| A broken image in an old browser | A shared cache served a cached AVIF without respecting Vary: Accept | Normalize the format into the cache key |
| The image CDN bill jumps | Unbounded widths, or bots requesting random transforms | Snap widths to a list; sign URLs |
| A long feed slows down and the tab crashes | Decoded bitmaps of many large images in memory | Right-size images; virtualize the list |
To find these, filter the DevTools Network panel to images and sort by size; hovering an image in the Elements panel shows its rendered size next to its intrinsic size. Lighthouse flags oversized images and older formats. In production, real-user monitoring with LCP attribution shows whether the LCP image's time goes to starting late, downloading, or rendering.
Check your understanding · Exercise 6
The team adds link rel="preload" as="image" href="/hero-1600.avif" for a hero whose img uses a srcset from 480w to 2400w. What happens on a phone that would pick 1200w? Select all that apply.
Accessibility: alt text and text in images
One image rule matters more than the rest: every img needs an alt attribute. Screen readers read it in place of the image, and browsers show it if the file fails to load.
- Informative images get a short description of what matters in context:
alt="Blue running shoe, side view", notalt="image"or the file name. - Decorative images get an empty
alt="". Screen readers then skip them. Leavingaltout entirely is different and worse: many screen readers read the file name instead. - Images inside links or buttons describe the action: a magnifier icon in a search button gets
alt="Search". - Text inside images cannot be resized, translated, or selected, and it is blurry when compressed. Put real text on top of the image with CSS instead. This also lets you use a more aggressive image quality, since there are no letter edges to protect.
Placeholders and fades need one more check: with prefers-reduced-motion, swap the image in without a long animation. And any lazy loading or placeholder logic must still leave a real img with alt in the DOM, not a div with a background, or assistive technology has nothing to read.
Check your understanding · Exercise 7
Which alt values are right? Select all that apply.
Using it in a system design interview
Images come up in almost every design question that shows content: a feed, a product listing, a gallery, chat attachments, a video platform. You rarely get a question about images alone; the interviewer watches whether you plan for them or forget them until asked.
When to bring it up
- In the data model. Give each image an id,
width,height, and a placeholder hash. That plans for layout stability before anyone asks. - In the API. Return image ids and dimensions, not fixed-size URLs; the client builds
srcsetfrom a shared width list. - In the performance section. Name the LCP image and how it gets priority; everything else is lazy with reserved space.
- In the upload flow: originals to object storage, a job that reads dimensions, fixes orientation, strips metadata, and computes a placeholder, then an image CDN for delivery.
A full plan for a product grid
Here is what a strong answer sounds like for a marketplace search page with 24 product tiles in a 4-column grid on desktop and 2 columns on phones.
- Sellers upload originals to object storage; an upload job stores width, height, and a ThumbHash.
- An image CDN delivers them with automatic format and quality, widths snapped to 240, 360, 480, 720, and 960.
- Each tile is an
imgwith thatsrcset,sizes="(min-width: 1024px) 25vw, 50vw",aspect-ratio: 1 / 1, andobject-fit: cover. - The first row loads eagerly, with
fetchpriority="high"on the LCP tile; the rest useloading="lazy". - Tiles show the ThumbHash as a placeholder, then fade in (no animation under reduced motion).
- Every tile has
alttext from the product title.
Check the numbers out loud. A 412 pixel phone at DPR 2.625 has 206 pixel slots, so the target is about 540 pixels and each tile fetches 720w, maybe 40 to 70 KB as AVIF. Without sizes, each tile would fetch 960w.
Think first
In a social app, a user posts a photo and sees it in their feed immediately, before the image CDN has ever seen it. How do you avoid a blank or jumping tile?
Show the local file at once: create an object URL from the selected File and render it in the post, with the width and height read from the decoded image before posting, so the tile has its final shape from the start.
Upload in the background. When the server confirms, it returns the image id, stored dimensions, and placeholder hash. Swap the object URL for the CDN srcset only after img.decode() on the new source resolves, so there is no flash, then revoke the object URL to free memory.
How deep to go by level
| Level | What the interviewer expects |
|---|---|
| Junior | Knows srcset exists, sets width and height, lazy-loads below the fold, writes good alt text. |
| Mid-level | Explains sizes and the DPR math, picks AVIF or WebP with a fallback, never lazy-loads the LCP image, uses an image CDN, plans placeholders. |
| Senior | Designs the pipeline: upload processing, dimensions and hashes in the data model, width snapping, cache keys with Vary, URL signing, cost, memory limits on long feeds, video with streaming, and how to measure all of it. |
Summary
- Ask four questions of every image: how many pixels, which format, how much space, and when it loads.
- Use
srcsetwithwdescriptors and an honestsizes; the browser picks the smallest candidate that reaches slot width times DPR. Missingsizesmeans100vw. - Use
picturefor format fallback (type) and art direction (media), not for plain resolution switching. - AVIF is usually smallest, WebP is a safe middle, JPEG is the fallback; measure savings on your own images instead of quoting a number.
- Put
widthandheighton everyimg(and each art-directedsource) so the browser reserves space; store dimensions at upload for user content. - Placeholders (color, LQIP, BlurHash, ThumbHash) make waiting feel shorter; keep inline ones to the first screen.
- Image CDNs make variants from URL recipes; snap widths to a list, normalize the format in the cache key, and sign URLs.
- Video: optimize the poster, use
preload="none"ormetadata, replace GIFs with muted looping video, and give motion a pause button.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
Walk me through how the browser picks a file from srcset with width descriptors.
It evaluates sizes against the viewport to get the slot width in CSS pixels: the first media condition that matches wins, and the last value is the fallback. If sizes is missing it assumes 100vw.
It multiplies the slot by the device pixel ratio to get a target in device pixels, then divides each candidate's width by the slot to get its density. It picks the smallest candidate whose density reaches the DPR. For a 412 pixel phone at DPR 2.625 with 100vw, the target is about 1082, so a 1200w file wins over 800w.
The spec leaves the final choice to the browser, so it may reuse a larger cached file or pick smaller under data saving. That freedom is why srcset beats choosing URLs in JavaScript: it happens during HTML parsing, before layout, and the preload scanner can start the download.
Question 2
When do you use picture instead of just srcset?
For two jobs. Format fallback, with type sources such as AVIF then WebP and a JPEG img. And art direction, with media sources when a phone needs a different crop, not just fewer pixels.
I avoid media for plain resolution switching because it removes the browser's freedom to choose. With an image CDN that negotiates formats from the Accept header, I drop the type sources and use picture only for crops. Each art-directed source gets its own width and height so the reserved box matches the crop.
Question 3
Our product page has a CLS of 0.25 and most shifts come from images. What do you do?
Confirm first: the Layout Instability API and DevTools show which elements moved. Then make sure every img has width and height attributes, which browsers turn into an aspect-ratio, with height: auto in CSS so fluid images keep the ratio.
For grids, fix the shape in CSS with aspect-ratio and object-fit: cover. For user uploads, store dimensions at upload time and send them in the API so the client can render them before the image exists in the cache. Check art-directed picture sources and video elements too, and ad slots, which are a common non-image cause.
Question 4
Design the image delivery for a marketplace where sellers upload millions of photos.
Upload originals straight to object storage with a signed URL. A background job reads dimensions, applies EXIF orientation, strips metadata for privacy, and computes a ThumbHash; those go into the listing record.
Serve through an image CDN with transforms in the URL: width snapped to a fixed list, fm=auto keyed on a normalized format bucket, automatic quality, and signed URLs so nobody can mint random transforms. The frontend builds srcset from the shared width list with a per-layout sizes.
On pages, the LCP photo is eager with fetchpriority="high", the rest lazy, all with reserved space and placeholder colors. I would track LCP, CLS, image bytes per page, CDN hit ratio, and transform cost.
Question 5
A landing page has a 6 MB animated GIF in the hero and an autoplaying background video. How would you improve it?
Convert the GIF to MP4 and WebM and play it with autoplay, loop, muted, and playsinline; video codecs store only changes between frames, so it is usually many times smaller. Give it an optimized poster and dimensions, because the poster may be the LCP element.
For the background video, use preload="none" or metadata if it is not the first thing seen, skip autoplay under prefers-reduced-motion or on data saver, and add a visible pause control, since WCAG requires one for motion that lasts more than five seconds.
Practice it
These design problems lean on images and media.
- Design an image carouselMedium
- Design an avatar image cropperMedium
- Design an emoji and GIF pickerMedium
- Design an image lightbox with zoomMedium
- Design an Instagram-style stories viewerMedium
- Design a search results pageMedium
- Design product ratings and reviewsMedium
- Design PinterestMedium
- Design a food delivery appMedium
- Design WikipediaMedium
- Design a plan to make a slow e-commerce site fastMedium
- Design an AI image generation studioMedium
- Design a news feedHard
- Design InstagramHard
- Design a TikTok-style vertical video feedHard
- Design YouTubeHard
- Design NetflixHard
- Design an e-commerce site like AmazonHard
- Design AirbnbHard
- Design a web photo editorHard
- Design Google PhotosHard
- Design a 3D product configuratorHard
References
- MDN: Responsive images srcset, sizes, and picture with worked examples.
- MDN: The picture element
- web.dev: Learn Images A full course on formats, compression, and responsive images.
- web.dev: Optimize Cumulative Layout Shift Why width and height prevent shifts.
- Google: WebP compression study The source of the 25 to 34 percent figure.
- web.dev: Replace animated GIFs with video
- MDN: The video element preload, poster, autoplay, and playsinline.
- HTTP Archive Web Almanac 2025: Page Weight
- Chromium: LCP change in Chrome 112 to ignore low-entropy images The 0.05 bits per pixel threshold for placeholders.
- Chrome for Developers: Shipping JPEG XL in Chrome
Read next
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- Loading strategiesDeciding what the browser loads first, what it loads later, and what it loads only when needed, so the pixels that matter show up fast.
- Back-of-the-envelope numbers for frontendQuick, rough math with round numbers (bytes over the network, CPU time on a cheap phone, memory in a long-lived tab, and requests per second at the server) that turns "this might be slow" into a reason to pick one design over another.
- CDN cachingKeeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
- HTTP cachingThe browser's built-in cache, steered by headers on each response, which lets a repeat visit skip the network entirely or ask only "has this changed?": the cheapest speed-up on the web.
- Performance budgets and monitoringAgreed limits on what a page may cost (kilobytes of JavaScript, LCP, INP) that CI checks on every pull request and real-user monitoring watches in production, so speed stops getting worse one small change at a time.