← All notes

Performance · Core Web Vitals · React

A 360° product viewer that doesn't hurt your LCP

Hundreds of frames, one hero video and a slow phone: how staged, low-priority, cancellable loading keeps an image-sequence viewer out of the way.

Radu Sadovei · · 8 min read

On the vehicles page of kids.lego.com there's a carousel of LEGO models you can spin around. Each model is an image sequence: 72 frames, one every 5°. Drag left or right and the frames swap, so it looks like a 3D object without any 3D on the page.

It looked great and loaded terribly. The page started downloading hundreds of frames immediately, for every model in the carousel. On a slow connection the hero video at the top of the page didn't start playing, because it was queuing behind images nobody had asked for yet. On low-end phones the whole page felt stuck.

The hero video is what the browser reports as the Largest Contentful Paint. The viewer isn't. So the rule I worked to was simple: the viewer must never compete with the LCP element. Here is how, in five steps.

1. Download smaller frames

The cheapest request is a smaller one. Every frame goes through an image optimizer that returns a modern format (WebP or AVIF) at the width the viewer actually renders, with the device pixel ratio capped at 2. A 3× iPhone does not need nine times the pixels to show a spinning car at 400px wide. I used quality 70 on mobile and 75 on desktop, which is plenty for frames that are each on screen for a fraction of a second.

frame-url.ts
// Ask the optimizer for exactly what this viewer needs: rendered width × DPR (capped at 2), at most 828px on phones
export function frameUrl(src: string, cssWidth: number, mobile: boolean) {
  const width = Math.round(cssWidth * Math.min(window.devicePixelRatio, 2));
  const capped = mobile ? Math.min(width, 828) : width;
  const quality = mobile ? 70 : 75;
  return `/_next/image?url=${encodeURIComponent(src)}&w=${capped}&q=${quality}`;
}

If the optimizer fails for any reason, the viewer falls back to the original file, so nothing ever breaks.

2. Load in stages, on real signals

Instead of "load everything on mount", each batch of frames waits for a signal that it's needed:

Staged loading timeline for a 360° viewerThe hero video loads first with nothing competing. The visible model's frames start only when the viewer scrolls into view, at low priority and fifteen at a time. Neighbouring models load when the browser is idle, six at a time. Everything else waits for the first drag and loads three at a time. Leaving the page cancels all pending downloads.page loadtime →Hero video (LCP)high priority, nothing competesVisible modelin view · 15 at a time · low priorityNeighboursbrowser idle · 6 at a timeEverything elsefirst drag · 3 at a timeviewer scrolls into view
Each stage waits for a real signal: visibility, idle time, or the user actually touching the viewer.
  1. Nothing until the viewer scrolls near the viewport (an IntersectionObserver).
  2. Then the visible model, so the first drag is smooth.
  3. When the browser is idle, the neighbours: the next and previous models in the carousel, so a quick swipe never lands on a model that isn't ready.
  4. Everything else only after the first real interaction. Someone who never touches the viewer downloads one model and its neighbours, and that's it.

3. Ask for low priority

Browsers let you say how important a request is. Viewer frames are explicitly low, so even when they are downloading, the hero video and the fonts go first. For <img> elements that's the fetchpriority="low" attribute; for fetch() it's the priority option.

4. Limit concurrency and cache everything

Firing 72 requests at once is still a traffic jam, even at low priority. A tiny promise pool keeps a fixed number in flight, with a different limit per stage: 15 for the visible model, 6 for the neighbours, 3 in the background. A shared cache makes sure a frame is never downloaded twice when you swipe back.

queue.ts
export type Enqueue = <T>(task: () => Promise<T>) => Promise<T>;

// At most `limit` tasks run at the same time; the rest wait their turn
export function createQueue(limit: number): Enqueue {
  let active = 0;
  const waiting: (() => void)[] = [];

  const next = () => {
    if (active >= limit) return;
    const run = waiting.shift();
    if (!run) return;
    active++;
    run();
  };

  return (task) =>
    new Promise((resolve, reject) => {
      waiting.push(() => {
        task()
          .then(resolve, reject)
          .finally(() => {
            active--;
            next();
          });
      });
      next();
    });
}
load-frame.ts
const cache = new Map<string, Promise<string>>();

export function loadFrame(url: string, enqueue: Enqueue, signal: AbortSignal) {
  let frame = cache.get(url);
  if (!frame) {
    frame = enqueue(async () => {
      const res = await fetch(url, { priority: "low", signal });
      if (!res.ok) throw new Error(`${res.status} ${url}`);
      return URL.createObjectURL(await res.blob());
    });
    // Aborted or failed: forget it so it can be retried later
    frame.catch(() => cache.delete(url));
    cache.set(url, frame);
  }
  return frame;
}

5. Cancel what nobody will see

If someone leaves the page or the viewer unmounts halfway through, the remaining downloads are wasted bandwidth on a phone that may be on mobile data. One AbortController per viewer cancels everything that's still queued or in flight.

use-preload.ts
// requestIdleCallback is missing in some browsers; a short timeout is a fine fallback
const whenIdle = (cb: () => void) =>
  "requestIdleCallback" in window ? requestIdleCallback(cb) : window.setTimeout(cb, 200);

export function usePreload(models: Model[], index: number, stage: RefObject<HTMLElement | null>) {
  useEffect(() => {
    const el = stage.current;
    if (!el) return;
    const controller = new AbortController();
    const visible = models[index];
    const neighbours = [models[(index + 1) % models.length], models[(index - 1 + models.length) % models.length]];
    const rest = models.filter((m) => m !== visible && !neighbours.includes(m));

    const load = (list: Model[], limit: number) => {
      const enqueue = createQueue(limit);
      return Promise.allSettled(list.flatMap((m) => m.frames.map((url) => loadFrame(url, enqueue, controller.signal))));
    };

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting) return;
        observer.disconnect();
        load([visible], 15).then(() => whenIdle(() => load(neighbours, 6)));
      },
      { rootMargin: "200px" },
    );
    observer.observe(el);

    const loadRest = () => load(rest, 3);
    el.addEventListener("pointerdown", loadRest, { once: true });

    return () => {
      controller.abort();
      observer.disconnect();
      el.removeEventListener("pointerdown", loadRest);
    };
  }, [models, index, stage]);
}

The order matters as much as the limits. Loading the visible model first and its neighbours right after means the carousel feels instant, even though most of it hasn't been downloaded.

What changed

The page no longer downloads hundreds of images on load, and the viewer's requests no longer compete with the hero video. The frames that do download are a fraction of their original size.

I'll be honest about one thing: we didn't have real-user monitoring wired up at the time, so I can't give you a before-and-after LCP number. If you build something similar, measure it from day one. The single most useful number is how many viewer requests start before LCP, and the target is zero.

measure.ts
new PerformanceObserver((list) => {
  const lcp = list.getEntries().at(-1)!.startTime;
  const early = performance
    .getEntriesByType("resource")
    .filter((r) => r.name.includes("/frames/") && r.startTime < lcp);
  console.log(`${early.length} viewer requests started before LCP`);
}).observe({ type: "largest-contentful-paint", buffered: true });

The general lesson

Most performance problems like this aren't about one slow thing. They're about the right things arriving in the wrong order. Decide what the page is for, protect that, and make everything else wait for a reason to load.