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.
Step-by-step resolution
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-ratioreservation 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.
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.
Related
- GPU Memory & Texture Management — where decoded images live
- Texture Memory Budget for Mobile — limits on how much to pre-decode
- Video and Canvas Layer Memory Costs — the other heavy media in animations