Image Decoding & Placeholders: From Bytes to Pixels

This topic extends Image & Media Optimization to the part of image delivery that happens after the network. When an image's bytes arrive, the browser still has to decode them — decompress the AVIF, WebP or JPEG into a bitmap — before it can paint. For a full-screen photo on a high-density phone, that bitmap can be 12 million pixels, and decoding it takes tens of milliseconds on mid-tier hardware, more for AVIF at high resolutions. Where and when that decode happens decides whether it delays LCP, blocks interactions, or happens invisibly in the background.

The same window — bytes requested, bytes arriving, pixels painted — is what placeholders exist to cover. A dominant-colour block, a tiny blurred preview (LQIP), or a BlurHash string can make a page feel complete while full images load, and when sized correctly they also prevent layout shift. Done badly, placeholders add bytes and JavaScript to the critical path, or even become the LCP element themselves. The thresholds remain LCP under 2.5s, INP under 200ms and CLS under 0.1.

An image's path from bytes to pixels Stages from the image request through download, decode and paint, with the placeholder covering the gap. An image's path from bytes to pixels Request discovered + prioritised Download bytes over network Decode bytes → bitmap Paint LCP recorded Placeholder covers until paint

The Degradation This Topic Addresses

Decode and placeholder problems surface as:

  • LCP render delay with the image already downloaded. The network waterfall shows the LCP image finished early, yet LCP is later; the gap is decode and paint, sometimes on a busy main thread.
  • Janky scrolling and slow interactions on image-heavy pages. Large images decoded synchronously during scroll or after interactions add long tasks.
  • CSS background heroes discovered late. A hero set via background-image cannot be found by the preload scanner, so its request — and therefore LCP — starts late.
  • Placeholders that cost more than they save. Base64 previews inflating HTML, BlurHash decoders in the startup bundle, or placeholders that shift when the real image arrives.

Decode time for one hero image on a mid-tier phone Bar chart of decode time for the same hero photo at different resolutions and formats on a mid-tier phone. Decode time for one hero image on a mid-tier phone JPEG 1080w 9ms AVIF 1080w 14ms JPEG 2400w (oversized) 38ms AVIF 2400w (oversized) 61ms Oversized images cost decode time as well as bytes; serving the displayed size is the biggest decode optimisation.

Prerequisites

  • DevTools Performance panel with screenshots, to see "Image Decode" events and their thread.
  • LCP phase attribution from the web-vitals attribution build (render delay in particular).
  • An image pipeline that can generate responsive sizes and tiny placeholders at build time or via an image CDN.
  • Knowledge of where heroes are defined — <img>, <picture>, or CSS backgrounds.

1. Environment Setup: Make Decode Visible

Record a throttled trace (4x CPU) of a hero-image page. In the Main and Raster threads, look for Image Decode events; note their duration and whether they occur on the main thread before the LCP paint.

2. Capture a Baseline

From field data, take LCP render delay p75 for image-LCP templates. From the lab, record decode duration for the hero, bytes of placeholder data inlined in HTML, and whether any placeholder library runs at startup.

3. Isolate the Bottleneck

If render delay is large and the trace shows long decodes, the image is oversized or decoding synchronously. If load delay is large and the hero is a CSS background, discovery is the issue. If CLS is attributed to image containers, placeholder sizing is the issue.

Diagnosing the gap between bytes and pixels Decision sequence for identifying whether decoding, discovery or placeholder sizing is the problem for an image. Diagnosing the gap between bytes and pixels Image downloaded early but LCP late? Decode or render delay — right-size and decode async yes no Hero is a CSS background-image? Late discovery — move to img or preload yes no Layout shifts when images arrive? Reserve space — sized placeholders yes no Check placeholder cost on the critical path

4. Apply the Fixes

  1. Right-size images so decode work matches the display — the largest decode saving.
  2. Use decoding="async" for non-LCP images and img.decode() before inserting images that appear on interaction — see using decoding=async and the decode() API.
  3. Keep decode off the critical interaction path — avoiding main-thread image decode jank.
  4. Move CSS background heroes into <img> or preload them — CSS background images as LCP elements.
  5. Choose placeholders by cost — LQIP vs BlurHash placeholders.

Deconstructing Render Delay for an Image LCP

PhaseWhat happensBudget (mobile p75)Lever
Load end → decode startbitmap decode scheduled< 20msmain thread free, async decode
Decodebytes → pixels< 30msright-sized image, format
Paint + rasterbitmap drawn to layers< 20msno expensive effects on the image
Compositingframe presented< 10msavoid huge layers

Render delay for a hero image, before and after right-sizing Timelines comparing the time after a hero image finishes downloading until it paints, for an oversized image on a busy main thread and a right-sized one. Render delay for a hero image, before and after right-sizing Before main thread busy decode 2400w paint After paint 0ms 50ms 100ms 150ms 200ms 250ms 300ms

Advanced Diagnostics and Edge Cases

Decode on the main thread. Chromium normally decodes images off the main thread, but some paths (synchronous decodes for images inserted and painted in the same frame, very large images, canvas drawImage of undecoded images) can block it. Long Image Decode events on the main thread are the tell.

AVIF decode cost. AVIF's smaller files come with heavier decoding than JPEG, especially at large sizes. For huge images on low-end devices, the byte saving can be partly offset; serving the right size keeps both small.

Progressive JPEG. Progressive JPEGs paint a low-quality version early; LCP is recorded when the first meaningful paint of the element occurs, which can improve perceived performance.

Image elements removed and re-added. Re-rendering image elements (common in client frameworks) can trigger repeated decodes; keep image nodes stable.

Memory pressure. Large decoded bitmaps consume memory; too many on one page can cause re-decoding when the browser discards them, adding jank on scroll.

Validation and Budgeting

Budget image dimensions against display size (no image more than ~1.5x its rendered width times DPR), track LCP render delay p75 in RUM, and keep placeholder bytes in HTML under a few kilobytes per page.

javascript
// Lab check: flag images decoded at far more pixels than displayed.
const oversized = [...document.images].filter((img) => {
  const r = img.getBoundingClientRect(); const dpr = devicePixelRatio;
  return img.naturalWidth > r.width * dpr * 1.5 && r.width > 0;
}).map((img) => `${img.currentSrc} ${img.naturalWidth}px for ${Math.round(img.getBoundingClientRect().width)}css px`);
console.table(oversized);
// trade-off: this checks the current viewport and DPR only; run it at your
// main breakpoints and on a high-DPR profile.

Code Examples

html
<!-- LCP hero: eager, high priority, sized; decoding left to the browser (sync is fine for LCP). -->
<img src="/img/hero-1200.avif" srcset="/img/hero-800.avif 800w, /img/hero-1200.avif 1200w"
     sizes="100vw" width="1200" height="675" fetchpriority="high" alt="Trail runner on a ridge">
<!-- trade-off: forcing decoding="async" on the LCP image can delay its paint by
     a frame in some browsers; leave the default for the hero. -->
css
/* Placeholder that never shifts: the container carries the aspect ratio and a dominant colour. */
.card-media { aspect-ratio: 4 / 3; background: var(--dominant, #e8e4dc); }
.card-media img { width: 100%; height: 100%; object-fit: cover; }
/* trade-off: a flat colour is the cheapest placeholder but conveys little;
   use LQIP only where the preview genuinely improves perceived quality. */

Worked Example: A Photography Marketplace

A photography marketplace served 2400px-wide JPEGs to every device and used a JavaScript BlurHash decoder for all thumbnails at startup. On mid-tier phones, the hero's render delay was 210ms (decode of an oversized image on a busy main thread), the BlurHash library and decodes added 140ms of startup scripting, and grid scrolling stuttered as large images decoded. The team generated responsive AVIF/JPEG sizes, kept the hero eager with default decoding, set decoding="async" on grid images, and replaced runtime BlurHash with build-time dominant colours plus 1KB LQIP previews only for above-the-fold cards. LCP render delay fell to 35ms, LCP p75 improved by 400ms overall, TBT dropped by 150ms, and grid scrolling became smooth.

Perceived Performance and Placeholders

Placeholders do not change LCP directly — LCP measures when the real image paints — but they change how fast the page feels, and they prevent layout shift. The best placeholder is the cheapest one that communicates layout and tone: a reserved box with a dominant colour costs nothing and prevents CLS; a tiny blurred preview conveys composition; BlurHash and ThumbHash give smoother gradients from a few dozen bytes but need decoding code. Make sure the placeholder never becomes the LCP element: a large, visible LQIP can be counted as an image candidate, recording a fast but meaningless LCP and then a later one when the real image replaces it. Keep placeholders as CSS backgrounds or low-entropy elements that do not compete as candidates.

Framework Image Components and Decoding

Framework image components make many of these decisions for you, and it is worth knowing which. Next.js next/image sets decoding="async" by default, lazy-loads unless priority (or fetchPriority="high") is set, and can render a blur placeholder from a tiny inline image; mark the hero with priority so it is eager and preloaded. Nuxt Image's <NuxtImg> passes through loading, decoding and fetchpriority, with preload for heroes and a placeholder option that generates tiny previews. Astro's <Image> generates responsive sizes at build time and supports loading="eager" and fetchpriority for the LCP image. In every case, two settings matter most: the hero must be eager and prioritised, and the component must emit real width/height (or an aspect ratio) so placeholders and final images occupy the same box.

jsx
// next/image: hero eager and prioritised; grid images lazy with async decode.
<Image src={hero} alt="" priority sizes="100vw" placeholder="empty" />
{products.map((p) => <Image key={p.id} src={p.image} alt={p.name} width={400} height={300} sizes="(max-width: 600px) 50vw, 25vw" />)}
// trade-off: placeholder="blur" inlines a base64 preview per image into the
// HTML payload; use it for a handful of large images, not every grid item.

Decode, Memory and Image-Heavy Pages

Pages with hundreds of images — galleries, feeds, product grids with infinite scroll — run into a second decode problem: memory. Every decoded bitmap occupies width × height × 4 bytes; a 1200×900 image is about 4MB decoded, regardless of how small its file was. Browsers discard decoded bitmaps for off-screen images under memory pressure and re-decode them when they scroll back into view, which shows up as stutter on low-memory devices. Serving images at display size is again the main fix (a 400×300 thumbnail decodes to under 0.5MB). Beyond that, virtualise long image grids so off-screen images are removed from the DOM, and avoid keeping full-size images in memory for lightboxes until they are opened. These steps reduce both jank and the chance of the browser tab being killed on low-end phones.

A Rollout Order for Decode and Placeholder Work

Sequence the work by impact and risk. First, right-size images — it reduces bytes, decode time and memory at once, and responsive image tooling makes it mechanical. Second, audit the LCP image on each template: eager, high priority, discoverable in HTML, default decoding. Third, set decoding="async" on everything else and fix any synchronous decode paths in JavaScript galleries and canvases. Fourth, standardise placeholders: aspect-ratio boxes with dominant colours everywhere, richer previews only where design calls for them. Measure LCP render delay and CLS after each step; most sites capture the majority of the benefit in the first two.

Common Pitfalls

  • Oversized images. Decode cost scales with pixels; serving 2x more width than needed means 4x the pixels.
  • decoding="sync" or forced decodes on many images. Blocks rendering for each.
  • Lazy-loading the LCP image. Adds load delay that no decode optimisation can recover.
  • Base64 LQIP for every image in HTML. Inflates HTML (often the LCP bottleneck itself).
  • Runtime BlurHash on the main thread for many images. Adds startup scripting.
  • Placeholders without aspect ratio. Causes layout shift when the real image arrives.
  • Removing and re-adding image elements on re-render. Triggers repeated decodes.

Measuring in the Field

Use LCP attribution's elementRenderDelay for image-LCP pages as the primary decode-related field metric, segmented by device class — decode cost is CPU-bound and shows most on low-end devices. For interaction-related decode jank, LoAF entries with long render phases on image-heavy views and scroll jank metrics (dropped frames during scroll) help. Track the share of images served larger than 1.5x their displayed size with a sampled in-page check; it is the leading indicator for decode cost.

FAQ

Does decoding="async" make images load faster?

No — it affects when decoding is allowed to happen relative to rendering other content, not download speed. It lets the browser paint other content without waiting for the image's decode.

Is AVIF slower to decode than JPEG?

Generally yes, per pixel. Its much smaller files usually make it a net win, especially when images are right-sized; for very large images on low-end devices, measure both.

Should the LCP image have a placeholder?

A reserved box with a background colour, yes. A visible blurred preview can briefly become the LCP candidate; if you use one, keep it as a CSS background on the container.

Can decoding block the main thread?

Usually decoding happens on other threads, but certain cases (synchronous decode for an immediately painted image, canvas draws, very large images) can block. Long Image Decode events on the main thread in a trace confirm it.

Are progressive JPEGs good for LCP?

They can improve perceived load by painting early scans; how LCP accounts for progressive paints has varied across browser versions. Do not choose JPEG over AVIF for this alone.

What is the cheapest effective placeholder?

A container with the right aspect ratio and the image's dominant colour as background. It prevents CLS, costs a few bytes of CSS, and needs no JavaScript.

Do image CDNs help with decode cost?

Indirectly: they serve the right size and format per request, which is the biggest lever on decode time. Some also generate placeholders (dominant colour, LQIP) on the fly, removing the need for a build step.

Does lazy loading affect decoding?

Lazy-loaded images are requested and decoded only as they approach the viewport, which spreads decode work across scrolling rather than concentrating it at load. Combine loading="lazy" with decoding="async" for below-the-fold images so decodes do not block frames during scroll.

Should placeholders be generated at build time or runtime?

Build time (or by an image CDN) whenever possible: the placeholder is then plain CSS or a tiny image with no JavaScript cost. Runtime generation makes sense only for user-uploaded images you cannot process ahead of time.

How do I see decode time in DevTools?

Record a Performance trace with CPU throttling and look for "Image Decode" events; selecting one shows the image URL and duration, and the track it appears on tells you whether it ran on the main thread or a raster/decoder thread.

Guides in This Topic