On a fashion e-commerce product detail page (PDP), the shopper lands on a hero packshot (the big product photo above size chips) and swipes through gallery slides fed from Sanity's image CDN. The page uses a native <img> with srcset and sizes, not next/image on this surface: too many gallery images on PDP had already crashed iOS Safari when routed through the Next image optimizer, so the team kept plain img tags and Sanity ?w= URLs.
Who hits the bug: mobile and tablet shoppers on 2× or 3× screens. Flow: open PDP, glance at the hero packshot, pinch-zoom mentally against crisp product cards on the same storefront. What broke: the hero looked soft, like a JPEG blown up, while listing cards beside it looked fine.
srcset and sizes are the browser's negotiation. sizes tells the layout width of the slot in CSS pixels. DPR (device pixel ratio) is how many physical pixels sit on each CSS pixel (many iPhones are 3×). The browser multiplies slot width by DPR, then picks a URL from srcset whose w descriptor best covers that need. Candidate starvation is when every w in srcset is smaller than that product, so the browser upscales soft pixels.
Ambitious sizes, timid srcset
The packshot markup used:
sizes="(max-width: 1024px) 100vw, (max-width: 1536px) 33vw, 30vw"On a phone below 1024px the slot is full viewport width. At 393 CSS px and 3× DPR the browser wants on the order of 1179 image pixels. On a 2× tablet in the middle breakpoint, 33vw can land near 500 CSS px and ask for 1000–1500 pixels depending on viewport.
The srcset builder only emitted three Sanity widths: defaultWidth/4, /2, and defaultWidth with defaultWidth=1000. The largest candidate was 1000w. Retina phones needed more; the browser took 1000w anyway and stretched it. Desktop 1× at 30vw looked acceptable, which is why the bug hid in QA that only checked laptop.
Product cards on the same Next.js storefront already listed 2000w and 3000w candidates from a different helper. Same CDN, same Sanity Image URL API, different multiplier tables per component. That divergence is the trap: checking "we have srcset" on one breakpoint, or fixing cards while PDP still starves.
PDP packshot: sizes vs srcset candidates
sizes=(max-width: 1024px) 100vw, (max-width: 1536px) 33vw, 30vw. Toggle wiring and DPR; watch picked width and blur.1000w @ 3×
- Target (slot × DPR)
- 1179px
- Browser picks
- 1000w (largest available)
sizes asked for ~1179px of image data. Nothing in srcset reached that width, so the browser upscaled 1000w and the packshot looks soft.
Try this on the page
Use the card PDP packshot: sizes vs srcset candidates above.
- Leave Starved (max 1000w) and DPR 3× selected. Read Target (slot × DPR) (~1179px) vs Browser picks (1000w). The fake packshot should look blurred and the badge should say candidate starvation.
- Click Fixed (through 3000w) without changing DPR. The pick should jump to 1500w or higher, blur should drop, and the ring around the frame should disappear.
- Click DPR 1× under Starved again. The target shrinks; 1000w may suffice and the image looks sharp even in broken wiring. That is why desktop-only review misses the bug.
One builder for packshot and hotspots
I consolidated PDP imagery through one PdpImage path and a shared buildResponsiveImage(image, { defaultWidth, multipliers, quality }) that prints Sanity w/h query params and a width-descriptor srcSet.
const PDP_WIDTH_MULTIPLIERS = [0.25, 0.5, 1, 1.5, 2, 3]
const CARD_WIDTH_MULTIPLIERS = [0.25, 0.5, 1, 2, 3]
export function buildResponsiveImage(image, { defaultWidth, multipliers, quality }) {
const widths = multipliers.map((m) => Math.round(defaultWidth * m))
// …map to Sanity CDN URLs with ?w= …
}The 1.5 step on PDP matters: a 3× phone often needs ~1500w, not a jump straight to 2000w when 1500 is enough bytes. Cards skip 1.5 because their slot is smaller. Shop-the-look hotspot images reuse the same PDP builder so width lists cannot drift again.
Playwright locks the contract: assert srcset contains 2000w and 3000w, read currentSrc and expect width ≤1000 on 1× desktop projects and ≥1500 on 3× mobile profiles, plus at least one tagged hotspot image so gallery regressions do not slip through silently.
Matching sizes to honest srcset candidates is a byte tradeoff against sharpness. I did not chase a fake LCP millisecond win in this post; the shopper-visible failure was softness on the hero. When you add retina sizes, extend the multiplier table in the same PR as the markup, and test the DPR your phone actually ships.
Happy coding! Sander