DEV Community

Cover image for Building a Live, User-Controlled Canvas Background System That Doesn't Kill Low-End Phones
Behan kumar
Behan kumar

Posted on

Building a Live, User-Controlled Canvas Background System That Doesn't Kill Low-End Phones

The idea

Most apps give you a static background. I wanted Pairly to feel alive instead, so I built "Atmosphere": a real-time animated Canvas layer that sits behind every chat, fully tunable by the user, speed, density, opacity, brightness, saturation, all live. There are currently over 40 atmospheres in the system, from calm ones like Snow and Fireflies to more elaborate ones like a black hole accretion disk called Abyss.

The interesting part wasn't drawing pretty particles. It was making that work smoothly on a five-year-old Android phone without draining the battery in ten minutes.

Two rendering paths, not one

Atmosphere isn't a single renderer, it's a small internal package (@pairly/atmospheres) with two shared engines that every individual atmosphere builds on:

  • ParticleCanvas, a generic particle system for anything made of many independent objects: snow, fireflies, sakura petals.
  • useCanvasLoop, a raw draw-loop hook for continuous scenes that aren't particle-based, like Abyss's swirling accretion disk.

Both engines centralize every "don't destroy the device" concern in one place, so individual atmospheres never have to think about it. Here's useCanvasLoop's frame loop:

const frameInterval = 1000 / perf.fps;
let raf = 0;
let last = performance.now();
let acc = 0;

const loop = (now: number) => {
  if (!running) return;
  raf = requestAnimationFrame(loop);

  const elapsed = now - last;
  last = now;
  acc += elapsed;
  if (acc < frameInterval) return;
  const dt = acc / 1000;
  acc = 0;

  draw(ctx, width, height, elapsedTime, perf);
};
Enter fullscreen mode Exit fullscreen mode

requestAnimationFrame fires at the display's native rate (often 90-120Hz on phones now), but that doesn't mean you should draw every single time it fires. This accumulator pattern throttles actual drawing down to the target FPS from the device's performance profile, instead of trusting rAF's raw rate.

Profiling the device before drawing anything

Before any atmosphere renders a single frame, it checks the device:

export function getPerformanceProfile(): PerformanceProfile {
  const cores = navigator.hardwareConcurrency ?? 4;
  const memory = (navigator as any).deviceMemory ?? 4;

  if (cores <= 4 || memory <= 4) {
    return { quality: "low", multiplier: 0.45, blur: false, fps: 30 };
  }
  if (cores <= 8 || memory <= 8) {
    return { quality: "medium", multiplier: 0.7, blur: true, fps: 60 };
  }
  return { quality: "high", multiplier: 1, blur: true, fps: 60 };
}
Enter fullscreen mode Exit fullscreen mode

That multiplier directly scales particle count:

const count = Math.max(
  minCount,
  Math.round(baseCount * (density / 100) * perf.multiplier),
);
Enter fullscreen mode Exit fullscreen mode

So a user's "density" slider is really a request, the actual particle count is that request scaled down by what the device can handle. A low-end phone silently gets 45% of the particles a high-end desktop would, without the user ever seeing a settings toggle for it. There's just a small note in the UI: "We've simplified effects to keep things smooth on this device."

The DPR trap

One line that's easy to skip and expensive if you do:

const dpr = Math.min(window.devicePixelRatio || 1, perf.quality === "low" ? 1.25 : 2);
Enter fullscreen mode Exit fullscreen mode

Modern phones report devicePixelRatio of 3 or 4. If you size your canvas buffer at native resolution on a low-end 4x-DPR phone, you're asking it to fill 16x the pixels of a naive 1x canvas, every frame. Capping DPR at 1.25 for low-end devices was one of the single biggest performance wins in this whole system.

Two other cheap wins

Pause when the tab isn't visible:

const onVisibility = () => {
  if (document.hidden) {
    running = false;
    cancelAnimationFrame(raf);
  } else if (!running) {
    running = true;
    last = performance.now();
    raf = requestAnimationFrame(loop);
  }
};
document.addEventListener("visibilitychange", onVisibility);
Enter fullscreen mode Exit fullscreen mode

No point animating a background nobody's looking at.

Respect prefers-reduced-motion:

if (reducedMotion) {
  draw(ctx, width, height, 0, perf); // one still frame, then stop
  return () => { /* cleanup */ };
}
Enter fullscreen mode Exit fullscreen mode

Users who've told their OS they don't want motion get one static frame instead of a continuous animation. Accessibility setting handled, animation budget saved.

The preview grid is intentionally not Canvas

The atmosphere picker shows a grid of live preview tiles, potentially ten or more on screen simultaneously. Running ten independent Canvas/rAF loops just for a picker grid would be wasteful. So previews use a completely separate, CSS-only component:

<Box
  sx={{
    animation: `atmosphere-${animation} ${2.4 + (i % 3) * 0.6}s ease-in-out infinite`,
    "@keyframes atmosphere-twinkle": {
      "0%, 100%": { opacity: 0.25, transform: "scale(0.8)" },
      "50%": { opacity: 1, transform: "scale(1.15)" },
    },
    // ...
  }}
/>
Enter fullscreen mode Exit fullscreen mode

Cheap CSS keyframes standing in for the real Canvas animation, good enough to give a sense of the effect without the actual rendering cost. Right tool for a grid of thumbnails versus a full-screen live background.

An actual atmosphere: Abyss

Most atmospheres are particle systems, but a few, like Abyss (a black hole with a swirling accretion disk), are hand-drawn continuous scenes on useCanvasLoop instead. It layers three things every frame: rotating accretion rings drawn as radial-gradient strokes, particles spiraling inward and warping as they approach center, and a static photon ring + event horizon on top:

ctx.globalCompositeOperation = "lighter"; // additive blending for the glow

ringsRef.current?.forEach((ring, i) => {
  const rot = time * 0.00008 * speed * (i % 2 === 0 ? 1 : -1);
  ctx.save();
  ctx.rotate(rot);
  const g = ctx.createRadialGradient(0, 0, ring.radius - ring.width, 0, 0, ring.radius + ring.width);
  g.addColorStop(0, "transparent");
  g.addColorStop(0.35, palette[1]);
  g.addColorStop(0.6, palette[2]);
  g.addColorStop(1, "transparent");
  ctx.strokeStyle = g;
  ctx.stroke();
  ctx.restore();
});
Enter fullscreen mode Exit fullscreen mode

globalCompositeOperation = "lighter" does a lot of the visual work here, overlapping particles and rings add their light together instead of just painting over each other, which is what gives it that glowing look instead of a flat one.

The weekend unlock, no database required

Most atmospheres are premium. Every weekend, one random premium atmosphere goes free for everyone. The fun detail: this needed zero database writes or scheduled jobs.

export function getWeekendAtmosphereUnlock(now: Date = new Date()) {
  const window = getWeekendUnlockWindow(now);
  const premiumAtmospheres = atmospheres.filter((a) => a.premium);

  if (!window.isActive || premiumAtmospheres.length === 0) {
    return { isActive: false, atmosphereId: null, windowEndsAt: null };
  }

  const pickIndex = window.weekendIndex % premiumAtmospheres.length;
  const picked = premiumAtmospheres[pickIndex];

  return { isActive: true, atmosphereId: picked.id, windowEndsAt: window.windowEndsAt };
}
Enter fullscreen mode Exit fullscreen mode

The pick is deterministic, derived from the week number modulo the number of premium atmospheres. Every client computes the same answer independently. No cron job, no row to write, no cache to invalidate. It just falls out of the math.

What's next

Still expanding the library (40+ atmospheres so far, from calm ones like Fireflies to elaborate ones like Abyss), and continuing to tune the performance profiling as more device data comes in.

If you're building something with live, user-tunable Canvas effects, I'd genuinely like to hear how you approached the low-end-device problem, that was the actual hard part here, not the visuals.

Try it live at pairly.chat.

Top comments (1)

Collapse
 
101beardo profile image
Tarun Sharma

The DPR cap is the detail most people skip, glad you called it out separately from the particle count multiplier. One thing worth flagging, navigator.deviceMemory only exists in Chromium, Safari and Firefox both return undefined so your profile always falls back to the memory=4 default on iOS. That means the cores check is doing all the real work on Safari, which is a problem if a lot of your low end traffic is an older iPhone rather than an old Android. Did you test the profiling on iOS specifically or is that still an open question