DEV Community

Serving a Large Art Set to a UI That Shows One Image

A card-drawing interface is a deceptively nasty frontend problem.

The interaction is trivial - tap, reveal one image - but the asset set is large, the art is the entire product, and the user is on a phone. Ship it naively and you have a 40 MB page showing a gray rectangle at the exact moment the experience is supposed to land.

Rule one: never ship the deck, ship the card

The instinct with a 78-card tarot set is to preload it so reveals are instant. Do the arithmetic first. Even at a disciplined 80 KB per card, 78 cards is 6 MB - before the back face, frame art, and whatever texture overlay makes it look good. On a mid-tier phone over cellular you've just spent the user's entire patience budget on 77 images they'll never see today.

The correct model is a one-card-plus-back critical path. Before first paint you need the card back (shown immediately, often animated), the frame chrome (ideally CSS or inline SVG rather than a raster), and the single card image for today's draw. Everything else is lazy. This turns an O(deck) problem into an O(1) problem, and every optimization after it is a rounding error by comparison.

The routing consequence: your draw must resolve early enough to start the image fetch during the round trip that serves the HTML. If the card is chosen client-side after hydration, you've serialized document โ†’ JS โ†’ decision โ†’ image fetch, and that chain is what you feel as slowness. Resolve server-side, then preload in the head:

<link rel="preload" as="image" fetchpriority="high"
  href="/cards/w1200/the-star.avif"
  imagesrcset="/cards/w480/the-star.avif 480w,
               /cards/w800/the-star.avif 800w,
               /cards/w1200/the-star.avif 1200w"
  imagesizes="(max-width: 640px) 88vw, 420px">

imagesrcset on a preload link is the part people miss - without it the preloader guesses a URL, downloads the wrong width, and you've paid twice. The actual <img> must then use a byte-identical srcset/sizes, or the preload doesn't match and is simply wasted.

Rule two: card art is not a photograph, so stop encoding it like one

Card illustration is usually flat-ish color with hard line work and large uniform regions - a different rate-distortion problem than a photo, and default encoder settings are tuned for photos. What actually moves the needle:

  • AVIF first, WebP fallback, no JPEG. For illustration with flat regions, AVIF's intra prediction is a large win over WebP, which is itself a large win over JPEG. Serve via <picture> and let the browser pick.
  • Cap the served dimension at what the layout displays. A card rendered in a 420 px column on a 3x display needs 1260 px, not the 4000 px master. This one change is usually worth more than every encoder flag combined.
  • Quantize the palette before encoding. If the art has limited colors, a tuned palette shrinks files substantially at no perceptible cost. Test per-asset; it helps on flat art and hurts on gradients.
  • Strip everything. Color profiles, EXIF, thumbnails - small per file and free to remove.

Bake it into a build step:

for src in art/masters/*.png; do
  name=$(basename "$src" .png)
  for w in 480 800 1200; do
    magick "$src" -resize "${w}x" -strip "tmp/$name.png"
    avifenc --min 22 --max 34 --speed 4 \
      "tmp/$name.png" "dist/cards/w$w/$name.avif"
    cwebp -q 82 -m 6 "tmp/$name.png" -o "dist/cards/w$w/$name.webp"
  done
done

Content-hash the filenames and serve with Cache-Control: public, max-age=31536000, immutable. Card art doesn't change; there's no reason for a browser to ever revalidate it. Highest-leverage caching decision in the system, and it costs one line of config.

Rule three: hide latency behind the animation you already have

Card reveals almost always have a flip or fade - typically 300-600 ms during which the front face isn't visible yet. That's free latency. Kick off the fetch, run the animation, and only commit the flip once the image has decoded:

async function revealCard(card) {
  const img = new Image();
  img.src = card.srcFor(currentDisplayWidth());
  const animation = flipToFront(el, { duration: 520 });
  try {
    // decode() resolves when the bitmap is paint-ready - no jank on flip.
    await Promise.race([img.decode(), timeout(1500)]);
  } catch { /* fall through to the placeholder */ }
  el.querySelector('.face').replaceChildren(img);
  await animation.finished;
}

img.decode() rather than the load event is the important bit. load fires when bytes have arrived; the decode still happens on the main thread at paint time, and for a large AVIF that's a visible hitch precisely mid-animation. Awaiting decode() moves that work off the critical frame.

Underneath, keep a tiny inline placeholder - a 20-ish px blurred version as a data URI, or a dominant-color fill. A few hundred bytes, renders in the first frame, and the layout never shows an empty box. Add explicit width / height (or aspect-ratio) so nothing reflows when the real image lands, and the reveal feels solid even on a bad connection.

Rule four: prefetch exactly one thing, at idle

One prediction is worth making: the user will probably open the card detail view. After the reveal settles, prefetch that view's larger asset - and nothing else - at idle:

requestIdleCallback(() => {
  const c = navigator.connection;
  if (c?.saveData || /2g/.test(c?.effectiveType ?? '')) return;
  document.head.appendChild(
    Object.assign(document.createElement('link'), {
      rel: 'prefetch',
      as: 'image',
      href: card.detailSrc,
    })
  );
}, { timeout: 3000 });

Respecting saveData and effectiveType isn't politeness, it's correctness - speculative prefetch on a metered connection competes with content the user actually asked for.

This "resolve early, ship one card, hide the fetch inside the flip" shape is what the daily draw at Oraclely runs on, and the interesting result was that it made deck size almost irrelevant to page weight.

Adding art changes build time and CDN storage, not what a visitor downloads. That's the mental model: treat the art library as a CDN concern and the page as a one-image concern. Almost every performance problem in this category comes from letting the size of the collection leak into the cost of a single visit. A card app should weigh about the same whether the deck holds 20 images or 2,000.

Comments

No comments yet. Start the discussion.