skip to content

You are setting up a pipeline that generates several widths of every photo on a site. How do you decide which widths to generate, and where does the ladder stop at the top?

level: middleimportance: should knowfreq 50%

answer

  1. layout width, not viewport breakpoints
  2. ceiling = widest layout width times max density
  3. geometric steps, not even steps
  4. one ladder per image role
  5. delete rungs nothing ever selects

basics

~20 s

Derive widths from the image's own maximum layout width at each breakpoint times the highest pixel density you commit to, not from viewport breakpoints. Space steps by a roughly constant percentage so savings are even, and cap the ladder where the largest layout can use it.

solid answer

~50 s

The mistake I would avoid first is mirroring the site's CSS breakpoints — an image that sits in a 480px column on a 1440px viewport needs nothing near 1440. I measure the widest CSS width that particular image role is ever laid out at, multiply by the top pixel density I commit to serving, and that number is the top of the ladder; anything above it can never be selected. Then I space the intermediate steps geometrically rather than evenly — roughly 25–30% growth per step, so 400, 520, 680, 880, 1140, 1440. Even 200px steps waste effort at the top, where two neighbours are near-duplicates, and are far too coarse at the bottom, where a step is a doubling. Five or six widths per image role is usually enough; I define the ladder per role — hero, card thumbnail, avatar — not per image, so assets stay shared and cacheable.

code

javascript · 15 lines
javascript
// Derive a role's ladder from its real layout widths at each breakpoint.
const layoutWidths = { mobile: 360, tablet: 480, desktop: 640 }; // CSS px for a card image
const MAX_DENSITY = 2;

const ceiling = Math.max(...Object.values(layoutWidths)) * MAX_DENSITY; // 1280

const geometricLadder = (min, max, factor = 1.3) => {
  const widths = [];
  for (let w = min; w < max; w = Math.round(w * factor)) widths.push(w);
  widths.push(max);
  return widths;
};

console.log(geometricLadder(360, ceiling));
// [360, 468, 608, 790, 1027, 1280]

go deeper

for a junior

Know that a responsive image means several files of the same picture at different widths, and that the widths should relate to how big the image is drawn, not how big the screen is.

for a middle

Be ready to derive the ceiling out loud — widest layout width times the density you support — and to explain why evenly spaced widths waste effort at the top and under-serve the bottom.

for a senior

Show the empirical loop: ship a ladder, then check which rungs real devices select, delete dead rungs and raise a ceiling that everything is piling onto. Argue for per-role ladders on cache-reuse grounds.

for a principal

Own the ladder as a platform decision — a small named set every team reuses, with a stated density commitment — and defend the cost of the generation pipeline against the bytes it actually saves.

## Start from the image's layout width, not the viewport The single most common error in building a responsive image ladder is to reuse the site's CSS breakpoints as image widths. Those numbers describe the *viewport*, and an image almost never occupies the whole viewport. A product card image inside a four-column grid on a 1440px desktop layout might be laid out 320px wide; generating a 1440px variant for it produces an asset no device will ever legitimately select, while the widths that actually matter cluster below 700px and may not exist at all. So the ladder is derived per *image role*, from that role's real layout width. Walk the breakpoints and write down what the CSS actually gives the image at each: hero 100% of viewport up to a 1280px container; card image one quarter of a 1200px grid minus gutters, so about 280px; avatar a fixed 48px. Those are three different ladders, and they should be, because they are three different byte problems. ## The top of the ladder The largest width worth generating is the widest CSS width the image is ever laid out at, multiplied by the highest device pixel density you commit to serving. For a hero capped at a 1280px container with a 2x commitment, that is 2560px, and generating a 3200px variant is pure storage cost — no selection algorithm will pick it, because no layout can use it. For the 280px card image, the top of the ladder is 560px, and the fact that phones exist with 1400 physical pixels of width is irrelevant: the *element* is 280 CSS px wide there too, or narrower. There is a second reason to cap deliberately. Source photos are frequently 4000–6000px straight out of a camera or a stock library. If the pipeline is "generate every width up to the original," you inherit gigantic assets that only exist because of the source, not because of the design. Anchor the ceiling in layout, not in the source. ## Spacing the steps Once the ceiling is set, the remaining question is how many steps and where. Even spacing is the intuitive choice and the wrong one. Consider 400, 800, 1200, 1600, 2000: the first step is a 100% jump in width and roughly a quadrupling in bytes, so a device needing 500px is forced up to 800 and over-downloads badly; meanwhile 1600 and 2000 are visually and byte-wise close neighbours, so the expensive end of the ladder carries near-duplicates. Geometric spacing fixes both ends. Pick a growth factor — 1.25 to 1.4 is the usual range — and generate 400, 520, 680, 880, 1140, 1440. Now every device over-downloads by at most the growth factor's worth of width regardless of where it lands, which is the property you actually want: bounded relative waste. A refinement some pipelines use is to space by *bytes* rather than by width: encode the image, then choose widths so each variant is roughly a fixed number of kilobytes larger than the previous one (say 20 KB). This adapts automatically to content — a flat gradient compresses so well that widely separated widths barely differ in size, while a detailed photo needs more rungs. It is more machinery than most teams need, but it is the principled version of the same idea. ```javascript // Geometric ladder: bounded relative over-download at every rung. const ladder = (min, max, factor = 1.3) => { const widths = []; for (let w = min; w < max; w = Math.round(w * factor)) widths.push(w); widths.push(max); return widths; }; // ladder(400, 1440) -> [400, 520, 676, 879, 1143, 1440] ``` ## How many rungs Five or six per role covers the realistic device space with bounded waste. Beyond that the marginal saving per added rung shrinks fast — the difference between a 4-rung and an 8-rung ladder is typically a few kilobytes per request — while build time, storage, and cache fragmentation all grow linearly. Below three rungs you are back to forcing large groups of devices onto a badly-fitting asset. ## Keep ladders shared, not per-image Define ladders per role and reuse them across every image in that role. Two benefits follow. First, the numbers stay reviewable: a handful of named ladders is something a team can reason about, where per-image ladders are not. Second, shared widths mean shared URL shapes and a smaller, hotter set of distinct assets at the edge, which matters more than the last few kilobytes of fit. Round the generated widths to tidy values for the same reason — a ladder of 400/520/680 across the whole site behaves better in caches than one image at 517 and another at 523. ## Sanity-check against reality After the ladder ships, verify it empirically: on real pages at real viewport sizes, check which rung devices actually land on. If everything is choosing the top rung, your ceiling is set below what the layout needs. If a rung is never chosen by anything, delete it — it is pure cost.

  • Your source photos are 6000px wide. Should the ladder include a 6000px rung?
    No. The ceiling comes from what the layout can use, not from what the source happens to be. If the widest placement is a 1280px container and you serve up to 2x, 2560px is the top; a 6000px rung costs storage and build time and can never be legitimately selected. Keep the original archived as a master for regeneration, but do not publish it as a delivery variant.
  • How would you know a rung in the ladder is dead weight?
    Measure which variant real sessions actually fetch. Field data or server-side logs of variant URLs give a distribution across rungs; any rung with essentially no traffic is generating and storing bytes nobody selects, and can be dropped. The same data catches the opposite failure — everything piling onto the top rung means the ceiling is set below what some layout genuinely needs.
  • Why round ladder widths to shared, tidy numbers instead of computing an exact fit per image?
    Because shared widths keep the set of distinct assets small. Every unique width is a separate cached object at the edge and in the browser, so per-image widths of 517 and 523 halve the reuse you would get from one shared 520. The last few kilobytes of fit are worth less than a warm cache and a ladder a human can reason about.

saying these in an interview costs you the question

  • Reuses CSS viewport breakpoints as image widths
  • Generates every width up to the source photo's size
  • Spaces variants evenly, doubling at the small end
  • Defines a bespoke ladder per individual image
  • Adds rungs indefinitely, assuming more fit is always better

context