Recipes / Performance

Recipes

Performance

What the components already do to stay cheap, which options cost the most, and how to load three.js only when it's needed.

Already handled

You don’t need to do anything for these:

  • No per-frame garbage: the idle waves are precomputed per tile, so a frame does no trigonometry and allocates nothing. On abhiramkrishna.com this took the grid from about 18% of CPU time to about 4.5%.
  • Sleeps when unseen: the loop stops when the canvas scrolls out of view or the tab is hidden, and starts again when it’s back. Check grid.isRunning to see it.
  • Capped pixel ratio: the canvas renders at most at 2× device pixels (maxPixelRatio).
  • Still frame for reduced motion: visitors who ask for less motion get one rendered frame and no loop at all. See Reduced motion.

The options that cost the most

Option Default Why it matters
maxPixelRatio 2 The GPU shades every pixel of the canvas. A full-screen background at 2× has four times the pixels of 1×. 1.5 is hard to tell apart behind text.
cols × rows 64 × 60 3,840 tiles, each updated every frame. Halving both quarters the work. Raise cellSize to cover the same area with fewer tiles.
bursts (skyline) true Spawns and moves particles. Turn it off, or raise burstLevel so only the busiest days burst.
TypeScript
new SpacetimeGrid(el, { maxPixelRatio: 1.5, cols: 48, rows: 44, cellSize: 0.45 });

cols, rows and cellSize set the size of the tile mesh, so they only apply when the grid is created. The framework wrappers recreate the grid for you when they change.

Load three.js later

three.js is the biggest part of the bundle. If the grid isn’t the first thing on screen, import it when its element comes into view:

TypeScript
const el = document.querySelector<HTMLElement>(".background")!;

const observer = new IntersectionObserver(async ([entry]) => {
  if (!entry?.isIntersecting) return;
  observer.disconnect();
  const { SpacetimeGrid } = await import("@abhiramkrishna/spacetime-grid");
  new SpacetimeGrid(el);
}, { rootMargin: "200px" });

observer.observe(el);

For a background that’s visible straight away, wait until the browser is idle instead, so it doesn’t compete with your page’s own content:

TypeScript
const whenIdle = window.requestIdleCallback ?? ((callback: () => void) => setTimeout(callback, 200));

whenIdle(async () => {
  const { SpacetimeGrid } = await import("@abhiramkrishna/spacetime-grid");
  new SpacetimeGrid(el);
});

Safari doesn’t have requestIdleCallback, hence the short timeout as a fallback.

Give the element the same background colour as the grid’s fog, so nothing jumps when the canvas appears. In Nuxt, <ClientOnly> with defineAsyncComponent does the same job. In Next.js, use next/dynamic with ssr: false.

Phones

  • Compact layout: below 1024px of canvas width (compactBreakpoint), the grid drops lower and shrinks, so it fits a narrow screen.
  • Skip the skyline: the bars get small on a phone. Show the 2D calendar there instead. It’s plain HTML with no three.js:
TypeScript
const small = matchMedia("(max-width: 640px)").matches;

if (small) {
  const { renderContributionCalendar } = await import("@abhiramkrishna/contribution-skyline/calendar");
  renderContributionCalendar(el, data);
} else {
  const { ContributionSkyline } = await import("@abhiramkrishna/contribution-skyline");
  new ContributionSkyline(el, data);
}

Clean up

Call destroy() when the element leaves the page, for example on a route change in a single-page app. It stops the loop, removes the listeners and frees the GPU memory. The Vue, React and Web Component wrappers do this when they unmount.

Measuring