Skip to content

Multiple animations on a page

The first few animations work, but later ones go black, freeze, or warn about WebGL context. Memory grows on navigation.

Use this when

  • A grid/list page with several Rive animations partially renders.
  • Console: WebGL: CONTEXT_LOST_WEBGL, Too many active WebGL contexts, or similar.
  • Memory keeps growing when users move between pages with Rive animations.

What you need

  • DevTools console + Memory tab.
  • Knowing whether you initialise each animation manually or via useRive / a wrapper.

Two problems, two fixes

Problem 1 — WebGL context limit

Browsers limit concurrent WebGL contexts to 8–16 (varies by browser/OS/GPU). Every default Rive instance creates its own context. After the limit, the oldest contexts are silently destroyed and those canvases go black.

Fix: enable the offscreen renderer on every instance. All instances then share one WebGL context.

Vanilla Web:

js
new Rive({
  src: "/animations/card.riv",
  canvas,
  autoplay: true,
  useOffscreenRenderer: true,
});

React:

tsx
useRive({
  src: "/animations/card.riv",
  autoplay: true,
  stateMachines: "Idle",
  useOffscreenRenderer: true,
});

Framer: the prebuilt Rive Framer Embed already sets this. If you wrote your own component, add it.

Problem 2 — Memory leak / context exhaustion across navigations

Rive allocates C++ objects via WASM. These are not garbage-collected by JavaScript. If you do not call cleanup(), every SPA route change leaks memory and consumes a WebGL context (even with offscreen, instances still hold renderer state).

Fix: call cleanup() when the canvas leaves the DOM.

Vanilla Web:

js
function tearDown() {
  r.cleanup();
}
window.addEventListener("beforeunload", tearDown);
// or call tearDown() from your router's "before navigate" hook

React — useRive cleans up automatically on unmount. But if you wrote a useEffect that creates a Rive instance manually, you must return a cleanup:

tsx
useEffect(() => {
  let r: any;
  (async () => {
    const rive = await import("@rive-app/webgl2");
    r = new rive.Rive({ /* ... */ });
  })();
  return () => r?.cleanup();
}, []);

Framer: the prebuilt component handles cleanup via its useEffect return.

Reuse one loaded .riv for many canvases

If you show the same animation in many cards, fetch the file once and reuse it:

js
import { Rive, load } from "@rive-app/webgl2";

const file = await load(new Request("/animations/card.riv"));

for (const canvas of document.querySelectorAll(".card-canvas")) {
  new Rive({
    rivFile: file,                // shared
    canvas,
    autoplay: true,
    stateMachines: "Idle",
    useOffscreenRenderer: true,
  });
}

This saves bandwidth and parse time. Each instance still has its own state.

Lazy load — only initialise when visible

For long pages, do not initialise off-screen animations:

js
const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    initRive(entry.target.querySelector("canvas"));
    observer.unobserve(entry.target);
  }
});

document.querySelectorAll(".rive-card").forEach(el => observer.observe(el));

Common mistakes

  • Not setting useOffscreenRenderer: true on any animation that might appear with others. The first 8–16 work, the rest go black.
  • Forgetting cleanup() on SPA navigations or component unmounts. Memory and contexts leak across visits.
  • Creating instances inside a list render without keys (React). Components remount on every re-render — old instances never clean up.
  • useOffscreenRenderer on only some instances — the ones without it grab dedicated contexts and push the others off.

If stuck, send this to Lupian

  • A page URL with multiple instances.
  • A short clip showing where it breaks.
  • Console output around the failure (search for WebGL and context).
  • See Ask Lupian for help.

Developer support for Lupian-delivered Rive animations