Image Decoding and Animation Jank

Part of GPU Memory & Texture Management in Performance Budgeting & GPU Architecture.

The problem

A lightbox opens with a scale-and-fade animation. The first two or three frames stutter, every time, on every device — and only when the image has not been shown before. A carousel advances smoothly between slides the user has already seen and jerks on the first pass. A hero image with an entrance animation drops frames on load but not on a second visit.

The animation is compositor-only in all three cases. What stutters is not the animation; it is the image being decoded on the frame it first appears.

Root cause analysis: decoding happens when the pixels are needed

A downloaded image is compressed data. Turning it into pixels — decoding — is significant work, roughly proportional to the decoded size in pixels, and browsers do it lazily: the image is decoded when it first needs to be painted.

If that first paint is the first frame of an animation, the decode lands inside the animation. Depending on the browser and image, decoding may happen on the main thread or on a decoder thread, but either way the first presented frame waits for pixels. Large images make this worse: a 4000 by 3000 photo displayed at 800 pixels wide still decodes at its full intrinsic size unless a smaller source is served.

Three platform features address it.

img.decode() returns a promise that resolves when the image is decoded and ready to paint. Awaiting it before starting an animation moves the cost out of the animation.

decoding="async" hints that the browser may decode off the main thread and not block presentation of other content; decoding="sync" asks for the opposite, which is occasionally useful for an image that must appear in the same frame as surrounding content.

fetchpriority and <link rel="preload"> get the bytes early, which is necessary but not sufficient — bytes still have to be decoded.

Layout matters too. An image without dimensions or aspect-ratio changes layout when it loads, which can shift content mid-animation and register as layout shift.

Opening a lightbox, with and without pre-decodingThe decode is the same work; only its position changes.Opening a lightbox, with and without pre-decodingWithout decode()decode in first framessmooth animation310 msWith await decode()decode before startsmooth animation310 msclickdecode in first framessmooth animationdecode before start
The decode is the same work; only its position changes.

Step-by-step resolution

Decoding before the animationMove the work earlier and tell the user something is happening.Decoding before the animation1Create the image element and set its source before the animation starts.Download begins early2Await img.decode(), with a timeout fallback.Pixels ready before motion3Show a placeholder or the thumbnail during the wait.No blank moment4Start the open animation once decoding resolves.Smooth first frames5Decode the next and previous images while one is displayed.Instant navigation6Serve sources sized close to the display size.
Move the work earlier and tell the user something is happening.

Production code pattern

const reduce = matchMedia('(prefers-reduced-motion: reduce)');

async function openLightbox(fullSrc, thumb) {
  const img = new Image();
  img.src = fullSrc;
  img.alt = thumb.alt;
  img.decoding = 'async';
  lightbox.replaceChildren(img);

  // Show the thumbnail while the full image decodes, but do not wait forever.
  lightbox.dataset.state = 'loading';
  await Promise.race([
    img.decode().catch(() => {}),                    // decode() rejects for broken images
    new Promise((r) => setTimeout(r, 400)),
  ]);

  lightbox.dataset.state = 'open';                   // triggers the CSS entrance
  preloadNeighbours();
}

function preloadNeighbours() {
  for (const src of [nextSrc(), previousSrc()]) {
    if (!src) continue;
    const img = new Image();
    img.src = src;
    img.decode().catch(() => {});                    // decode ahead; ignore failures
  }
}
.lightbox img {
  inline-size: 100%;
  block-size: auto;
  aspect-ratio: 3 / 2;                               /* reserve space before pixels arrive */
}

.lightbox[data-state="loading"] { opacity: 0; }
.lightbox[data-state="open"] {
  animation: lightbox-in var(--motion-enter-duration) var(--motion-enter-easing) both;
}
@keyframes lightbox-in { from { opacity: 0; scale: 0.96; } }

@media (prefers-reduced-motion: reduce) {
  .lightbox[data-state="open"] { animation: none; opacity: 1; }
}

Rendering Impact: composite for the entrance, with decoding moved before it. The aspect-ratio reservation prevents layout shift when the image’s intrinsic size becomes known.

The 400ms race matters for slow connections: waiting indefinitely for a decode would leave the lightbox blank. After the timeout the animation starts anyway, and the image appears when it is ready — a worse experience than a decoded start, but better than an apparently broken control.

Longest frame during lightbox open, 2400 px photoMid-range phone, warm cache for bytes.Longest frame during lightbox open, 2400 px photoNo pre-decode118 msdecoding=async only74 msawait decode() before animating19 msPre-decoded neighbour14 ms
Mid-range phone, warm cache for bytes.

Sizing and format

Decoding cost scales with decoded pixels, so serving an image at three times its displayed size costs roughly nine times the decode work. srcset with sizes, or a server that returns an appropriately sized variant, is the largest single saving — usually larger than any animation optimisation. Modern formats reduce bytes but still decode to the same pixel count, so they help download time more than decode time.

For carousels, decode only the neighbours rather than the whole set: decoding twenty images to make one transition smooth allocates memory for images the user may never reach, which is the memory pressure covered in texture memory budget for mobile.

Verification checklist

Constraints and trade-offs

  • decode() rejects for images that fail to load; always catch.
  • Pre-decoding holds decoded pixels in memory, so limit it to immediate neighbours.
  • A decode timeout trades a smooth start for responsiveness on slow connections.
  • decoding="sync" can delay presentation of surrounding content.
  • Very large images may be decoded in tiles as they are painted, so the cost can appear gradually rather than in one frame.

Frequently asked questions

Why does my image animation stutter only the first time?

The image is decoded when it is first painted, which is inside the animation. Afterwards the decoded pixels are cached, so later runs are smooth.

What does img.decode() do?

It returns a promise that resolves when the image is decoded and ready to paint, letting you move the decode cost before an animation starts.

Is decoding=“async” enough on its own?

It helps by allowing off-main-thread decoding, but it does not guarantee the image is ready when your animation starts. Await decode() for that.

Does preloading fix decode jank?

Preloading fetches bytes early but does not decode them. Combine preload for the bytes with decode() for the pixels.