Fixing Blurry Images on High-DPI Displays
An image that looks crisp on a standard monitor but renders soft, fuzzy, or smeared on a retina laptop or a flagship phone is almost always a pixel deficit: the file you served has fewer device pixels than the screen demands. This page is the diagnostic companion to responsive images with srcset and sizes, and it sits within the broader Image & Media Optimization guide. High-DPI displays pack 2 or 3 physical pixels into every CSS pixel, so a 400px-wide slot on a devicePixelRatio: 2 screen needs an 800px image to look sharp. Serve 400 actual pixels into that slot and the browser upscales them, producing the characteristic softness. The fix is never to sharpen the image — it is to ship enough pixels.
Rapid Diagnosis Checklist
Before changing markup, confirm the deficit in DevTools in under a minute:
- Read the effective DPR. In the console run
window.devicePixelRatio. A value of 2 or 3 means the screen needs 2x or 3x the CSS-pixel count. A value of 1.25 or 1.5 (common with Windows display scaling or browser zoom) also matters and is frequently overlooked. - Find the chosen candidate. Run
document.querySelector('img.blurry').currentSrc. This is the exact file the browser selected — not what you intended, what it actually used. - Compare intrinsic vs rendered size. In the Elements panel, hover the
<img>and read the tooltip (intrinsic dimensions) against the Computed tab'sRendered size. If intrinsic width < rendered width × DPR, you have a deficit. - Check for CSS upscaling. In Computed styles, see whether
width/heightexceed the image's intrinsic dimensions, which stretches a small file across a large box. - Inspect the CDN response. In the Network tab, check the requested URL's transform parameters and the
content-length; an over-aggressivew=or quality parameter can starve the file of detail.
If currentSrc points at a file whose width is smaller than rendered width × DPR, jump to the matching root cause below.
Root Cause Analysis
Cause 1: No 2x candidate in srcset (or no srcset at all)
The most common cause is a candidate set that tops out at the 1x size. If your largest srcset entry is image-400.jpg 400w and the slot is 400px, a 2x screen needs 800px and there is nothing to give it, so the browser upscales the 400w file. The same happens with a bare src and no srcset — there is exactly one file and it is the 1x file. The browser is doing the right thing; the supply is simply absent.
Cause 2: A sizes attribute that under-reports the slot width
With w descriptors, the browser computes the required pixel width as resolved sizes length × DPR. If sizes claims the slot is narrower than it really is — for example sizes="50vw" on an image that is actually full-bleed at 100vw — the browser computes too small a requirement and picks a candidate that is sharp by its (wrong) math but blurry on screen. This is insidious because it only shows up at certain viewport widths and DPRs, passing every desktop-at-1x review.
Cause 3: CSS stretching the image past its intrinsic size
Independent of srcset, a width or layout rule can render a small image into a large box. A 200px avatar forced into a 96px → 192px hover state, or a thumbnail with width: 100% inside a wide container, gets stretched by the renderer. Here the file may be exactly 1x-correct for its intended size but is being displayed larger than it was ever provisioned for.
Cause 4: CDN downscaling or over-compression
An image CDN transform can silently strip pixels. A URL parameter like ?w=400 caps the delivered width at 400 regardless of the srcset entry's stated 800w, so the descriptor and the actual bytes disagree — the browser trusts 800w but receives a 400px file. Aggressive quality parameters (e.g. q=40) or a forced format conversion can also smear high-frequency detail into mush even at the correct dimensions.
Step-by-Step Resolution
Fix 1: Add the missing high-density candidates
Extend the candidate ladder so it reaches at least 2× the largest CSS slot, then let srcset supply the extra pixels.
<!-- Before: tops out at 1x, blurry on retina -->
<!-- <img src="avatar-200.jpg" width="200" height="200" alt="Jordan Lee"> -->
<!-- After: 1x + 2x + 3x candidates for a fixed 200px slot -->
<img
src="avatar-200.jpg"
srcset="avatar-200.jpg 1x, avatar-400.jpg 2x, avatar-600.jpg 3x"
width="200" height="200"
alt="Jordan Lee" decoding="async">
<!-- trade-off: adding a 3x candidate sharpens flagship phones but the 600px file
is ~2.25x the bytes of the 2x file. Skip the 3x entry if your analytics show
few DPR-3 devices — the perceptual gain over 2x is small and rarely worth it. -->
Expected outcome: on a DPR-2 screen the browser selects avatar-400.jpg, eliminating upscaling. Softness on retina avatars disappears; the visible "fuzzy edge" artifact resolves to crisp 1:1 device pixels.
Fix 2: Correct the sizes attribute to match the real slot
Measure the rendered width at each breakpoint (Elements → Computed → Rendered size) and write sizes to match exactly, including container max-widths and gutters.
<img
src="hero-800.jpg"
srcset="hero-400.jpg 400w, hero-800.jpg 800w,
hero-1200.jpg 1200w, hero-1600.jpg 1600w"
sizes="(max-width: 600px) 100vw,
(max-width: 1024px) 100vw,
1100px"
width="1600" height="900"
alt="Conference keynote stage" decoding="async">
<!-- trade-off: an accurate sizes value can push large screens to the 1600w file,
adding bytes. That is correct for a hero, but for a non-critical below-fold
image consider capping the ladder so you never ship the very top candidate. -->
Expected outcome: the browser now computes the true requirement (100vw × DPR on mobile) and selects the 800w–1600w candidate instead of an under-sized one. Blur that appeared only at specific widths is eliminated; currentSrc should jump one or two ladder steps higher on the affected viewport.
Fix 3: Stop CSS from upscaling the image
Cap the rendered size to the image's intrinsic dimensions, or provision a larger file for the box it actually occupies.
/* Never let the rendered box exceed what the file can fill at this DPR */
.thumb {
width: 100%;
max-width: 192px; /* matches the intrinsic 192px asset */
height: auto;
image-rendering: auto; /* do NOT use crisp-edges as a "fix": it sharpens pixels, not detail */
}
/* trade-off: capping max-width keeps it crisp but prevents the image from filling
wider containers. If the design genuinely needs a larger render, the real fix is
a larger source file via srcset, not relaxing this cap. */
Expected outcome: the image renders at or below its intrinsic size, so no stretching occurs. The fix removes the "stretched and soft" look on large viewports without adding any bytes.
Fix 4: Align CDN transform parameters with the descriptors
Make the CDN deliver the width the descriptor promises, and back off over-aggressive compression. See image CDNs and fetchpriority for generating the ladder on demand.
<!-- Descriptor width and CDN w= parameter must agree -->
<img
src="https://cdn.example.com/hero.jpg?w=800&q=75&fm=auto"
srcset="https://cdn.example.com/hero.jpg?w=400&q=75&fm=auto 400w,
https://cdn.example.com/hero.jpg?w=800&q=75&fm=auto 800w,
https://cdn.example.com/hero.jpg?w=1600&q=75&fm=auto 1600w"
sizes="(max-width: 600px) 100vw, 800px"
width="1600" height="900" alt="Mountain trail at dawn" decoding="async">
<!-- trade-off: q=75 balances sharpness and weight; pushing to q=90 restores fine
detail but inflates bytes 30-50% and risks the LCP budget. Raise quality only
for images where artifacts are visibly objectionable, not site-wide. -->
Expected outcome: the bytes delivered match the descriptor, so the browser's selection is honored and high-frequency detail survives compression. Mush from over-compression clears; the 800w slot now receives a true 800px file.
Verification
Confirm the fix the same way you diagnosed it. Reload with cache disabled (the browser caches a chosen candidate per DPR, so a stale cache hides the change), then re-run document.querySelector('img').currentSrc and verify it points at a file whose width is ≥ rendered width × devicePixelRatio. Toggle DevTools device emulation to a DPR-3 profile and repeat — the selected candidate should step up accordingly. Visually, the softness should be gone at 100% zoom on the high-DPI display where it first appeared.
For field validation, watch the metric this most affects. The hero is usually the Largest Contentful Paint element, and over-correcting (shipping a 1600w file to every phone) trades blur for a slower LCP, so confirm both the sharpness and the byte budget moved in the right direction. In CI, assert Lighthouse's uses-responsive-images audit plus an LCP budget of 2500ms so a future regression — a deleted sizes prop or a reverted candidate — fails the build before it ships.
Related
- Responsive images with srcset and sizes — the full workflow for building the candidate ladder and writing a correct
sizesattribute. - Image CDNs and fetchpriority — generate every density on demand and prioritize the LCP image's fetch.
- Serving AVIF and WebP with fallbacks — recover the bytes that higher-density candidates add by switching to a denser codec.
- Measuring LCP with Chrome DevTools — make sure sharpening the hero did not regress the loading metric.
- Image & Media Optimization — the parent guide tying media weight to Core Web Vitals.