Your site feels “fine” on your laptop. Then you open it on a midrange phone on hotel Wi‑Fi and watch the page jitter like it’s nervous, while the hero image arrives fashionably late and wrecks your LCP.
This isn’t a mysterious front-end vibe problem. It’s image handling: missing aspect ratios, lazy loading done with the subtlety of a sledgehammer, and placeholders that look cheap or block rendering. The fixes are boring, mechanical, and absolutely worth it.
The three rules: reserve space, load intentionally, decode smoothly
If you remember nothing else, remember these rules. They map directly to production failures.
1) Reserve space (aspect ratio is not optional)
The browser can’t lay out a page if your images are a surprise. Without width/height or an aspect ratio, it guesses. Then it learns the truth after the network fetch, and the layout shifts. That’s CLS. Users hate it. Google measures it. Your support inbox experiences it.
2) Load intentionally (lazy load the right things, not the important things)
Lazy loading is a tool, not a moral virtue. Lazy loading your above-the-fold hero image is like locking the front door from the inside: technically secure, functionally disastrous.
3) Decode smoothly (placeholders and decode behavior matter)
Even after bytes arrive, images need to decode. Huge JPEGs can tie up the main thread. Placeholders help users tolerate the wait, and good decode behavior prevents jank during scroll.
One reliable ops maxim applies here too: “Hope is not a strategy.”
— paraphrased idea attributed to Gene Kranz. If your image behavior depends on “it’ll probably load fast,” it won’t in the one network condition that matters: the customer’s.
Quick facts and historical context (that actually matter)
- Browsers used to have no way to reserve space for an image unless you specified
widthandheightattributes. People stopped doing it for “clean HTML,” and CLS was born. - JPEG is older than the web: standardized in the early 1990s. It’s still everywhere because it compresses photos decently and is universally supported.
- PNG came out in the mid‑1990s as a patent-free replacement for GIF. It’s great for lossless and transparency, and terrible for big photographic banners.
- WebP was introduced in 2010 to cut image bytes, and it did. But it also introduced a decade of conditional delivery and “why is Safari blank?” folklore.
- AVIF arrived later (built on AV1) and can beat WebP for many photos at similar quality. Encoding is slower; your pipeline needs to cope.
- Native lazy loading (
loading="lazy") landed broadly in modern browsers around 2019–2020, replacing a cottage industry of scroll listeners and regret. - IntersectionObserver (2017-ish) made JS lazy loading less awful by avoiding scroll-event spam and helping browsers optimize.
- Core Web Vitals (2020) made LCP/CLS/INP the language of budgets. Images are the main character in two of those three metrics.
Aspect ratio: stop shipping layout shift
Layout shift caused by images is self-inflicted. The browser isn’t trying to sabotage you. It’s trying to lay out a page with incomplete information.
The “just set CSS width:100%” trap
If your HTML is <img src="..."> with no dimensions, and your CSS says img { max-width: 100%; height: auto; },
you’ve told the browser: “This will be responsive, but I won’t tell you how tall it is until after you download it.”
The result: the browser paints text, then the image arrives and pushes everything down. That’s CLS. It’s also the precise reason some sites feel “jumpy.”
What to do instead (do one of these, not none)
- Set
widthandheightattributes on every<img>. Modern browsers use them to compute aspect ratio and reserve space, even when the image is responsive via CSS. - Use CSS
aspect-ratiofor non-img containers (like background images, or when usingpicturewith tricky art direction). - Use an intrinsic ratio wrapper (padding-top hack) only if you’re stuck supporting old browsers or a broken CMS. It works, but it’s a maintenance smell.
Concrete pattern: the boring correct way
Put the pixel dimensions in HTML. Let CSS scale it.
cr0x@server:~$ cat /tmp/example.html
<img
src="/images/product-800.jpg"
width="800"
height="600"
alt="Product photo"
style="max-width:100%;height:auto;"
>
That width/height pair reserves a 4:3 box before the network request finishes. The page becomes stable. Your CLS gets less exciting.
When aspect ratios change (the CMS roulette problem)
In production, images are not a tidy set of 16:9 rectangles. Marketing uploads a portrait shot into a landscape slot. The “correct” fix is policy: enforce aspect ratio at upload or generate safe crops server-side.
The engineering fix is to design components that tolerate variability:
- Use
object-fit: coverwhen cropping is acceptable. - Use
object-fit: containwhen full visibility matters (but accept letterboxing). - Decide which is acceptable, don’t let it happen accidentally.
Lazy loading styles: native, JS, and the bad kind
Lazy loading is a bandwidth optimization and a render stability tactic. It is not a performance trophy you hang on every image. Misapplied lazy loading will inflate LCP and make your site slower where it counts.
Native lazy loading: your default
For below-the-fold images, use:
cr0x@server:~$ cat /tmp/lazy.html
<img src="/images/gallery-1200.jpg" width="1200" height="800" loading="lazy" decoding="async" alt="Gallery">
loading="lazy" lets the browser decide when to fetch based on viewport distance and heuristics. It’s generally better than your handcrafted scroll math.
When not to lazy load
Do not lazy load:
- The hero image that is likely the LCP element.
- Critical UI icons that appear immediately (and are not inlined).
- Images visible on first paint, especially on common viewport sizes.
If you lazy load the LCP image, you force the browser to wait until after layout/scroll heuristics decide it’s needed. You’re basically asking for a slower start, politely.
Joke #1: Lazy-loading the hero image is like putting the fire extinguisher in a locked cabinet labeled “Break glass in case of fire.” The fire will respect your process.
JS lazy loading: only when you need advanced behavior
You may need JS when:
- You’re swapping
srcbased on client hints or user settings. - You need to coordinate with virtualized lists.
- You’re implementing custom progressive loading with priority control.
Use IntersectionObserver. Do not bind a scroll handler that reads layout on every tick. That path leads to dropped frames and self-loathing.
“Lazy loading via CSS background-image” (don’t)
Background images aren’t images in the browser’s loading model. You lose srcset, lose native lazy loading, and often lose easy preloading and decoding hints.
If an image conveys content, use <img> or <picture>. Background images are for decoration. Yes, this is a hill worth dying on.
Blur-up placeholders: perceived performance without lies
Blur-up is the art of showing a tiny, blurry preview immediately while the real image loads. It’s not magic. It’s just managing the user’s impatience with a cheap first paint.
What blur-up is (and isn’t)
- Is: a small placeholder (often 10–30px wide, heavily compressed) stretched and blurred to fill the reserved box.
- Is not: an excuse to ship 4MB images because “users won’t notice.” They will. Their data plan will also notice.
Implementation options
- LQIP (low-quality image placeholder): a tiny JPEG/WebP data URI or a tiny file cached by the CDN.
- Blurhash: store a short string representing a blurred approximation; render it on client or server as a canvas or SVG.
- SVG dominant color: a lightweight “average color” placeholder. Less pretty, but very cheap.
Operational reality: placeholders can be a footgun
Inline base64 placeholders increase HTML size. That can delay TTFB-to-first-render, especially on server-rendered pages where HTML is the critical payload. If you inline a 2KB placeholder for 40 images, you’ve quietly added 80KB before compression overhead and markup.
Prefer:
- Inline placeholders only for above-the-fold images or a small curated set.
- For galleries, store tiny placeholders as separate cached assets or use Blurhash strings (very small) if you can render cheaply.
Blur-up + aspect ratio: inseparable
Blur-up without reserved space is just a blurry version of layout shift. The correct sequence is:
- Reserve space using width/height or aspect-ratio.
- Paint placeholder (blurred preview) immediately.
- Swap in the final image when decoded.
Responsive images: srcset, sizes, and the bandwidth tax
Responsive images are where performance is won or lost quietly. Not with heroics. With correct srcset and sizes.
The browser can’t guess your layout
If you provide srcset but omit or lie in sizes, the browser guesses wrong and downloads the wrong candidate. Often it downloads a much larger image than needed. That’s bandwidth waste and slower LCP.
Concrete example: width descriptors
cr0x@server:~$ cat /tmp/responsive.html
<img
src="/images/hero-800.jpg"
srcset="/images/hero-400.jpg 400w,
/images/hero-800.jpg 800w,
/images/hero-1200.jpg 1200w,
/images/hero-1600.jpg 1600w"
sizes="(max-width: 600px) 100vw, 600px"
width="1600"
height="900"
alt="Hero image"
fetchpriority="high"
>
Meaning: on small screens, the image will take the full viewport width. On larger screens, it will be displayed at 600px wide. The browser picks a file close to that displayed size times device pixel ratio.
Art direction: picture is your friend
When mobile needs a different crop than desktop, use <picture> rather than hoping “object-fit” will read your mind.
Format negotiation
Use picture sources for AVIF/WebP and fall back to JPEG/PNG. Don’t do UA sniffing. It’s fragile and makes incident response harder.
Critical path: LCP images, preload, and priority
Most pages have one image that matters more than the rest: the LCP candidate. Treat it like a first-class dependency.
Rules for the LCP image
- Don’t lazy load it.
- Ensure it is discoverable early in HTML (avoid hiding it behind client-side rendering if you can).
- Use
fetchpriority="high"on the<img>when appropriate. - Consider
rel="preload"if the browser would otherwise find it too late (for example, when CSS background images are involved—another reason not to use them).
Preload: sharp tool, use carefully
Preloading too many images steals bandwidth from CSS/JS and can make everything slower. Preload one, maybe two, and only when you’re sure they’re above the fold.
Joke #2: Preloading eight images is like calling eight taxis because you’re in a hurry. You’ll be late, but very popular with the taxi company.
Decode and rendering
Use decoding="async" for most non-critical images. It nudges browsers to decode off the main thread when possible.
For the LCP image, the browser may ignore it if it wants. That’s fine; you’re communicating intent, not issuing a subpoena.
Image pipeline and storage/CDN realities
Front-end markup is half the battle. The other half is your image pipeline: storage layout, cache keys, transformations, and operational hygiene.
Generate derivatives, don’t resize on the fly for free
Dynamic resizing at the edge can be great—until you get a burst of cache misses and your image transformer becomes the hottest service in your fleet.
Pre-generate common sizes for common layouts. Use on-the-fly resizing as a controlled fallback, not the only plan.
Cache keys and “infinite variants”
The fastest image is the one you already cached. But image resizing APIs invite unbounded parameters:
width, height, format, quality, crop mode, background color, DPR, sharpen… congrats, you’ve created a cache-miss generator.
Practical approach:
- Whitelist sizes (e.g., 320, 480, 640, 800, 1200, 1600).
- Clamp quality and strip metadata.
- Normalize parameters and order them deterministically to avoid cache fragmentation.
Storage and origin performance still matters
If your CDN misses and your origin is slow, the user pays. Watch I/O latency on the image origin, especially if you store originals on networked storage.
SRE reality: “It’s just static files” becomes “Why are we saturating the object store GET rate?” on a random Tuesday.
Compression and formats: pick defaults
Default format strategy that works for most sites:
- Photos: AVIF (primary), WebP (secondary), JPEG fallback.
- Logos/icons with transparency: SVG when possible; otherwise PNG/WebP lossless.
- Don’t use PNG for large photos unless you enjoy paying for bandwidth.
Practical tasks: 12+ commands, outputs, and decisions
These are the kinds of tasks you run during performance work and incident response. Each includes a command, sample output, what it means, and what decision you make next.
Task 1: Identify your largest images on disk (quick triage)
cr0x@server:~$ cd /var/www/site/public/images && find . -type f -printf "%s %p\n" | sort -nr | head
8421932 ./hero/original-homepage.jpg
5211033 ./blog/2024/launch.png
3328810 ./products/widget-x/angle-1.jpg
2988801 ./gallery/event-photos/001.jpg
2559012 ./team/headshots/ceo.jpg
What it means: You have multi-megabyte assets shipped as-is.
Decision: These are your first conversion targets (resize + modern format + quality tuning). Also check whether any of these are above the fold.
Task 2: Inspect image dimensions and format (are you serving a poster as a thumbnail?)
cr0x@server:~$ identify -verbose /var/www/site/public/images/hero/original-homepage.jpg | head -n 20
Image:
Filename: /var/www/site/public/images/hero/original-homepage.jpg
Format: JPEG (Joint Photographic Experts Group JFIF format)
Geometry: 6000x4000+0+0
Colorspace: sRGB
Depth: 8-bit
Filesize: 8.03MiB
Interlace: None
Orientation: Undefined
What it means: 6000×4000 is a camera original. Nobody needs that for a hero displayed at 1200–1600 CSS pixels wide.
Decision: Generate derivatives; cap maximum width; strip EXIF; consider AVIF/WebP.
Task 3: Convert a JPEG to WebP and compare size (sanity check)
cr0x@server:~$ cwebp -q 80 /var/www/site/public/images/hero/original-homepage.jpg -o /tmp/hero.webp
Saving file '/tmp/hero.webp'
File: /var/www/site/public/images/hero/original-homepage.jpg
Dimension: 6000 x 4000
Output: 1243876 bytes Y-U-V-All-PSNR 41.35 44.07 44.13 42.16 dB
What it means: A huge reduction in bytes at acceptable quality for many photos.
Decision: Use WebP or AVIF in production, but don’t ship the original dimensions—resize first.
Task 4: Resize to a reasonable derivative and encode (what users will actually download)
cr0x@server:~$ convert /var/www/site/public/images/hero/original-homepage.jpg -resize 1600x -strip -quality 82 /tmp/hero-1600.jpg
cr0x@server:~$ ls -lh /tmp/hero-1600.jpg
-rw-r--r-- 1 cr0x cr0x 312K Dec 29 10:11 /tmp/hero-1600.jpg
What it means: You cut an 8MB original into a 312KB derivative at a sane display width.
Decision: Build a derivative set (e.g., 400/800/1200/1600), wire it via srcset.
Task 5: Confirm HTTP caching headers from the edge (are you making the CDN work?)
cr0x@server:~$ curl -I https://cdn.example.com/images/hero-1600.jpg
HTTP/2 200
content-type: image/jpeg
content-length: 319488
cache-control: public, max-age=31536000, immutable
etag: "a1b2c3d4"
accept-ranges: bytes
age: 86400
via: 1.1 varnish
What it means: Long cache lifetime with immutable is excellent for versioned filenames. Age indicates cached delivery.
Decision: Keep this policy for hashed assets. If you don’t use fingerprinted names, don’t use one-year caching without a purge strategy.
Task 6: Check if the CDN is frequently missing (origin pain hidden behind “static”)
cr0x@server:~$ awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -nr | head
9241 200
812 304
119 206
37 404
What it means: Mostly 200s. That’s not enough; you need cache hit/miss info from CDN logs or headers.
Decision: If you can’t see hit rates, add a response header like X-Cache at the edge, or enable CDN logging. Observability beats guesswork.
Task 7: Find images missing width/height in server-rendered HTML (CLS audit)
cr0x@server:~$ curl -s https://www.example.com/ | grep -oE '<img[^>]*>' | head
<img src="/images/hero-1600.jpg" class="hero">
<img src="/images/logo.svg" alt="Company">
<img src="/images/promo.jpg" loading="lazy">
What it means: Those <img> tags have no intrinsic dimensions in markup.
Decision: Fix templates/components to emit width and height (or an aspect-ratio wrapper) for every content image.
Task 8: Identify where the LCP image comes from (HTML vs CSS background)
cr0x@server:~$ curl -s https://www.example.com/ | grep -i "background-image" | head
.hero { background-image: url("/images/hero-1600.jpg"); }
What it means: Your hero is a CSS background image. Browsers discover it after CSS loads and parses. That can delay the fetch and hurt LCP.
Decision: Prefer an <img> hero. If you must keep CSS, consider preloading the hero image and ensure CSS is critical and fast.
Task 9: Measure image request timings from the browser (field-style sanity)
cr0x@server:~$ chromium --headless --disable-gpu --dump-dom https://www.example.com/ >/dev/null
[1229/101322.114:WARNING:headless_shell.cc(618)] Running in headless mode.
What it means: Headless runs confirm the page renders, but they don’t expose timings by default.
Decision: Use a real performance trace tool in CI or local runs; for ops triage, rely on server/CDN headers and log timing. Don’t pretend headless DOM dump is a performance test.
Task 10: Verify Brotli/Gzip isn’t being wasted on images (it usually is)
cr0x@server:~$ curl -I -H 'Accept-Encoding: br,gzip' https://cdn.example.com/images/hero-1600.jpg
HTTP/2 200
content-type: image/jpeg
content-encoding:
What it means: No content-encoding for JPEG, which is correct. Compressing already-compressed images wastes CPU and can increase size.
Decision: Ensure your web server/CDN doesn’t attempt gzip on image/*.
Task 11: Check origin disk latency (because “static” still hits storage on cache miss)
cr0x@server:~$ iostat -x 1 3
Linux 6.8.0 (img-origin-01) 12/29/2025 _x86_64_ (8 CPU)
avg-cpu: %user %nice %system %iowait %steal %idle
3.12 0.00 1.58 7.90 0.00 87.40
Device r/s rkB/s rrqm/s %rrqm r_await rareq-sz w/s wkB/s w_await aqu-sz %util
nvme0n1 92.0 18432.0 0.0 0.0 12.40 200.3 8.0 1024.0 4.10 1.30 78.0
What it means: r_await around 12ms and 7–8% iowait suggests storage latency is a contributor during read bursts.
Decision: If CDN miss rate is high, fix caching first. If miss rate is normal but latency is high, move hot images to faster storage, add local caching, or scale origins.
Task 12: Confirm cache efficiency at the OS level (are files hot or constantly evicted?)
cr0x@server:~$ grep -E 'MemFree|Cached|Buffers' /proc/meminfo
MemFree: 812340 kB
Buffers: 122144 kB
Cached: 18342392 kB
What it means: A healthy page cache (Cached) can serve repeated image requests quickly on origin.
Decision: If cache is tiny and you’re thrashing, reduce origin misses, add RAM, or put an HTTP cache (like nginx proxy_cache) in front of storage.
Task 13: Validate that your image filenames are cache-friendly (hashing/versioning)
cr0x@server:~$ ls -1 /var/www/site/public/images | head
hero-1600.jpg
hero-1200.jpg
logo.svg
promo.jpg
What it means: Filenames look stable and non-fingerprinted.
Decision: If you want year-long caching, use fingerprinted names (e.g., hero-1600.a1b2c3.jpg) or a versioned path. Otherwise, keep cache lifetimes shorter and plan purges.
Task 14: Check if images are being served with the right MIME type (silent breakage)
cr0x@server:~$ curl -I https://cdn.example.com/images/hero-1600.webp
HTTP/2 200
content-type: application/octet-stream
content-length: 512044
cache-control: public, max-age=31536000, immutable
What it means: Wrong content-type. Some browsers/CDNs tolerate it, others don’t, and security policies can block it.
Decision: Fix origin/CDN MIME mapping for WebP/AVIF. This is a “works in staging” classic.
Fast diagnosis playbook
When a fast site suddenly feels slow, you don’t have time for a philosophical debate about image best practices. You need a tight loop: identify the bottleneck, fix the highest-impact issue, verify.
First: is it LCP, CLS, or “general slowness”?
- Users report “page jumps”: suspect missing aspect ratios and late-loading fonts/images causing layout shift.
- Users report “blank hero / slow first view”: suspect LCP image discovery/priority and oversized payload.
- Users report “scrolling feels janky”: suspect too many images decoding on main thread, heavy JS lazy loaders, or huge DOM + images.
Second: confirm the critical image behavior
- Is the hero an
<img>or a CSS background? - Is it lazy loaded by accident?
- Does it have correct dimensions reserved?
- Is it too large (bytes or pixels) for its display size?
Third: check delivery and caching
- Are CDN responses cache hits (
Agerising,X-Cache: HITif you have it)? - Is the origin slow (I/O wait, high read latency)?
- Are cache-control headers correct and consistent?
Fourth: check responsive image correctness
srcsetpresent with multiple widths?sizesaccurate for the layout?- Are you shipping 1600w to a layout that displays at 360px?
Fifth: placeholders and decoding
- Do you have blur-up placeholders for critical images?
- Are placeholders bloating HTML and delaying first render?
- Are you decoding a bunch of huge images during scroll?
Common mistakes: symptom → root cause → fix
1) Symptom: CLS spikes on content pages
Root cause: Images missing width/height attributes or aspect-ratio wrappers. Ads/embeds also do this, but images are the usual suspects.
Fix: Emit intrinsic dimensions from your CMS/build pipeline. If dimensions are unknown, store them at upload time and require them in templates.
2) Symptom: LCP gets worse after “adding lazy loading everywhere”
Root cause: Above-the-fold images are lazy loaded, so the browser delays fetching them until late.
Fix: Remove loading="lazy" from the LCP candidate and other above-the-fold images. Consider fetchpriority="high" and preload if discovery is delayed.
3) Symptom: Mobile downloads huge images despite srcset
Root cause: Missing or incorrect sizes attribute. The browser assumes the image will be full viewport width or uses a default that doesn’t match your layout.
Fix: Set correct sizes based on your actual CSS breakpoints and container widths. Re-check after layout changes.
4) Symptom: Images appear late, even though bytes are small
Root cause: The image is referenced in CSS (background-image) discovered after CSS download/parse; or the image URL appears after client-side JS runs.
Fix: Use <img> in HTML for content/hero. If CSS is unavoidable, preload the image and ensure critical CSS is inlined or loaded early.
5) Symptom: Scroll jank when many images enter viewport
Root cause: Decoding many large images at once; JS lazy loader causing layout thrash; too-large images for their slot.
Fix: Use native lazy loading, constrain image dimensions, reduce byte size, add decoding="async", and avoid custom scroll listeners.
6) Symptom: Cache hit rate is poor; origin CPU spikes on image transforms
Root cause: Unbounded transformation parameters create infinite variants, preventing caching.
Fix: Whitelist derivative sizes, normalize parameters, cap quality settings, and pre-generate common variants. Monitor variant cardinality.
7) Symptom: Some browsers show broken images for AVIF/WebP
Root cause: Wrong Content-Type, bad picture fallback ordering, or CDN misconfiguration.
Fix: Serve correct MIME types and use <picture> with AVIF/WebP <source> elements before the JPEG/PNG <img> fallback.
8) Symptom: HTML payload gets heavy after adding blur-up placeholders
Root cause: Base64 placeholders inlined for many images on one page.
Fix: Inline only critical placeholders; otherwise use tiny cached placeholder files or compact placeholder encodings like Blurhash strings.
Three corporate mini-stories from the trenches
Mini-story 1: The incident caused by a wrong assumption
A retail site rolled out a “simple” redesign: bigger product images, cleaner typography, fewer distractions. They did the right thing in one sense—moved images behind a CDN and enabled aggressive caching.
The wrong assumption was quiet: “If images are cached, they can’t cause incidents.” Nobody treated image delivery as a production dependency anymore. The team focused their monitoring on APIs and checkout flows, not static assets.
Then a subtle change landed in the image pipeline. A new transformer started outputting WebP for some variants but served them with a generic MIME type. Most browsers shrugged. A subset didn’t. The support tickets arrived first: “Product images missing on iPhone.” The incident channel lit up later.
The fix wasn’t heroic. It was humiliatingly basic: correct MIME types at the origin and edge, plus a test that fetched a representative set of images and asserted the response headers matched the file extension.
The lasting improvement was cultural: images went back onto the reliability dashboard. Latency, cache hit rate, and error codes were tracked like any other production service, because that’s what they are.
Mini-story 2: The optimization that backfired
A publishing platform decided to chase a better Lighthouse score. Someone noticed that many images below the fold were loading eagerly, so they added loading="lazy" to every image component. One line change. A clean diff. The kind of thing that gets praised.
The regression didn’t show up in desktop tests. It showed up in field data: LCP got worse on mobile. The top article image was now lazy loaded because the component was shared everywhere, including the hero slot. On fast networks it was fine. On slower networks it became the bottleneck.
The team doubled down at first. They added a bigger root margin in a JS lazy loader, thinking it would “start earlier.” That created another problem: the JS ran during scroll, did extra work, and the page started dropping frames when multiple images entered view.
The eventual fix was surgical: hero images were explicitly loading="eager" (or simply omitted loading), plus fetchpriority="high". Everything else used native lazy loading. They also enforced width/height attributes, which reduced CLS and made the page feel less frantic.
The lesson wasn’t “lazy loading is bad.” It was “global optimizations applied blindly become global regressions.” Production is where abstractions go to get audited.
Mini-story 3: The boring but correct practice that saved the day
An enterprise SaaS dashboard had a strict build pipeline rule: every uploaded image got probed for dimensions, then derivatives were generated at fixed widths. Those dimensions were stored alongside the asset and injected into HTML as width and height. The rule had been around forever. Everyone considered it “legacy hygiene.”
A new feature launched: user-generated content blocks with embedded images and a masonry-like layout. It was a perfect storm for layout shift. The product team was worried about the visual instability, and engineering was bracing for late-night bug reports.
The bug reports didn’t come. CLS stayed sane. The layout behaved because every image had a reserved box from the moment the HTML hit the browser. Even when images were slow, the page was stable. Users could scroll without the UI rearranging itself.
Later, when the team added blur-up placeholders, it was straightforward. The reserved space already existed, so placeholders improved perceived performance instead of masking chaos.
That pipeline rule wasn’t glamorous. It didn’t get a celebratory Slack thread. It quietly prevented an entire class of issues, which is the highest compliment production can offer.
Checklists / step-by-step plan
Step-by-step plan: ship stable, fast images in two weeks
- Inventory your images. Identify the top offenders by bytes and by page criticality (home page, top landing pages).
- Fix aspect ratios first. Add
width/heightattributes oraspect-ratiowrappers in your components/templates. This is the fastest CLS win. - Define derivative widths. Pick a small set of canonical widths and stick to them. Don’t allow arbitrary widths from query params unless you clamp.
- Enable modern formats with fallback. AVIF/WebP first, JPEG/PNG fallback via
picture. - Wire
srcset+sizes. Makesizesmatch real layout widths. Re-check after CSS changes. - Implement lazy loading intentionally. Default
loading="lazy"below fold. Never lazy load the LCP candidate. - Prioritize the LCP image. Ensure early discovery; consider
fetchpriority="high"and (sparingly) preload. - Add blur-up placeholders for key images. Don’t inline placeholders for dozens of images; choose critical slots.
- Lock down caching. Use fingerprinted filenames; apply long cache lifetimes with
immutable; validate CDN hit rates. - Add tests. Validate response headers (MIME types, cache-control), and validate HTML includes intrinsic dimensions for
<img>. - Observe in production. Track CLS/LCP in real user monitoring; also track CDN hit rate and origin latency.
- Make it policy. Enforce image upload rules: maximum dimensions, accepted formats, and required metadata capture.
Deployment checklist (pre-merge)
- Every content
<img>haswidthandheight(or a deliberateaspect-ratiocontainer). - Above-the-fold images are not lazy loaded.
srcsetincludes multiple widths andsizesreflects real CSS layout.- Hero/LCP image is in HTML (not CSS) unless there’s a documented exception.
- AVIF/WebP delivered via
picturewith correct fallback. - Cache headers are correct for fingerprinted assets.
- MIME types correct for WebP/AVIF/SVG.
- Placeholders don’t balloon HTML payload.
FAQ
1) Do I still need width and height if CSS controls the size?
Yes. The HTML attributes provide the intrinsic aspect ratio so the browser can reserve space before the image downloads. CSS can still scale it responsively.
2) Is aspect-ratio CSS enough on its own?
It can be, especially for containers or when you can’t easily inject dimensions. But if you have the true dimensions, width/height on the <img> is simpler and more portable.
3) Should I lazy load everything below the fold?
Usually yes, with native loading="lazy". But beware of “below the fold” being device-dependent. On tall screens, what you thought was below the fold might be visible immediately.
4) Why did my LCP get worse after adding lazy loading?
You probably lazy loaded the LCP candidate (often the hero). The browser delayed fetching it. Remove lazy loading for that image and consider fetchpriority="high".
5) Are blur-up placeholders worth it?
For image-heavy or visually-driven pages, yes—especially for the hero and first few images. But don’t inline dozens of base64 placeholders and pretend you didn’t just move the bytes into HTML.
6) Should I use Blurhash or LQIP?
LQIP is straightforward and looks great, but can add bytes (especially if inlined). Blurhash is tiny as a string, but you pay render complexity (canvas/SVG) and must implement it carefully.
7) Is WebP enough, or should I add AVIF?
WebP is widely supported and a good baseline. AVIF can be smaller for many photos at similar quality, but encoding cost is higher. If your pipeline can handle it, ship AVIF with WebP fallback.
8) How do I know if sizes is correct?
If mobile devices consistently download larger candidates than the rendered size would suggest, sizes is likely wrong or missing. Fix it to match actual CSS container widths at breakpoints.
9) Can I rely on the CDN to fix my image performance?
A CDN helps delivery, not fundamentals. It won’t fix missing aspect ratios, incorrect sizes, or shipping a 6000px image into a 400px slot. Also, cache misses still hit your origin—plan for that.
10) What’s the simplest “good enough” setup for a small team?
Emit width/height, use srcset/sizes with a small derivative set, native lazy loading below fold, and long caching for fingerprinted assets. Add blur-up only for the hero.
Conclusion: next steps you can actually ship
Fast sites don’t happen because someone sprinkled “lazy” on image tags. They happen because you remove uncertainty: reserve space, deliver the right bytes, and prioritize what matters.
Next steps:
- Pick your top 5 pages and identify the LCP image on each. Make sure it’s not lazy loaded, not hidden in CSS, and not oversized.
- Enforce intrinsic dimensions for every image component. Treat missing
width/heightas a bug, not a suggestion. - Define and generate a fixed derivative set. Wire it into
srcsetand write accuratesizes. - Decide on placeholder strategy: none, dominant color, LQIP, or Blurhash. Then do it consistently and sparingly where it counts.
- Instrument delivery: cache headers, CDN hit rate, origin latency. When something regresses, you’ll know where, not just that.