Topic: Performance, mobile, and battery considerations for animated web backgrounds Date: 2026-03-29 Context: BibleWeb SvelteKit app — pixel art atmospheric scenes with particle systems, animated water, flickering campfire, floating dust. Primary audience includes users 55+, often on older/lower-end mobile devices.
Animated backgrounds are achievable on mobile without destroying battery life, but only with disciplined implementation. The core principle: let hardware go idle as much as possible. Every frame you render when nothing visible has changed is wasted power. Every layer you GPU-composite unnecessarily stresses memory. The good news: a campfire with 50–100 particles, a slowly animated water layer, and floating dust are well within what a 2018-era mid-range phone can handle at 30fps — if you use the techniques in this document.
requestAnimationFrame (rAF) syncs to the display refresh rate, which is typically 60Hz (16.67ms per frame) or higher on newer devices (90Hz, 120Hz). Running a full atmospheric scene at 120fps on a flagship phone is pure waste for a Bible study app.
For atmospheric backgrounds — not gameplay — 30fps is visually indistinguishable from 60fps and halves the GPU workload. The standard technique uses delta-time gating:
const TARGET_FPS = 30;
const FRAME_INTERVAL = 1000 / TARGET_FPS; // 33.33ms
let lastFrameTime = 0;
function tick(timestamp: number) {
animationFrameId = requestAnimationFrame(tick);
const elapsed = timestamp - lastFrameTime;
if (elapsed < FRAME_INTERVAL) return; // skip frame
// Snap to frame boundary to avoid drift
lastFrameTime = timestamp - (elapsed % FRAME_INTERVAL);
update(elapsed);
render();
}
The elapsed % FRAME_INTERVAL correction prevents accumulated drift where skipped frames push timing off-budget.
Safari on iOS throttles rAF to ~30fps automatically when the device enters Low Power Mode — and throttles all CSS animations too. This is a confirmed WebKit behavior. Design for 30fps as your baseline; do not assume 60fps will be available.
Safari throttles rAF in cross-origin iframes until the user clicks inside them. This affects embedded demos and third-party widgets, but not the main app frame.
resistFingerprintingWhen Firefox's resistFingerprinting privacy setting is enabled, JavaScript timer accuracy drops to 100ms resolution — enough to "swallow six whole animation frames." This is a small edge case but worth knowing. Serving with Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp enables high-resolution timers.
Run a single shared rAF loop for all atmospheric effects rather than separate requestAnimationFrame calls per particle system or layer. Multiple concurrent rAF callbacks multiply overhead and make pause/resume harder to reason about.
The browser has a compositor thread that can animate certain CSS properties entirely off the main thread — transform and opacity are the two that are always compositor-safe. This means a CSS opacity pulse on a flame sprite, or a CSS transform translate on a drifting dust particle, will not block JavaScript execution.
Canvas and WebGL elements are naturally GPU composited as their own layers. You get this for free.
will-change: transform (or will-change: opacity) tells the browser to promote an element to its own GPU layer ahead of time, reducing the cost of the first frame. But it comes with a hard cost:
will-change hints can crash the browser tab by exhausting GPU memory/* Good: scoped, temporary */
.campfire-sprite {
will-change: transform;
}
/* Bad: applied globally to everything */
* { will-change: transform; }
For our use case: apply will-change: transform to the canvas element itself, not to individual particles (which live inside the canvas and are not DOM elements).
transform: translateZ(0) was the old way to force GPU layer promotion before will-change existed. It still works, but will-change is the correct modern approach. Both create a new stacking context, which can clip content unexpectedly.
Apply contain: strict (or contain: layout paint) to the wrapper element housing the canvas. This tells the browser that nothing inside can affect layout outside, preventing any layout-recalculation cascade caused by the animation:
.atmosphere-container {
contain: strict;
width: 100%;
height: 300px; /* must have explicit dimensions with contain: strict */
}
contain: strict implies size, layout, paint, and style containment — the animated canvas cannot trigger repaints in sibling DOM elements.
Never animate width, height, top, left, margin, padding. These trigger layout recalculation across the entire document — catastrophically expensive on mobile. Always animate transform: translate() instead of top/left.
Modern CPUs and GPUs clock up (increase frequency) under load to handle work, then idle back down when there is nothing to do. The idle state uses dramatically less power than the active state. An animation that runs at 60fps keeps the GPU clocked high continuously. An animation at 30fps allows micro-idle periods between frames.
From WebKit's engineering guidance: "The more performance required from the chips, the lower their power efficiency." Thermal throttling (covered below) is the downstream consequence.
Specific implications for our stack:
Canvas (2D API) is CPU-rendered by default, though modern browsers can GPU-accelerate portions of it. WebGL is GPU-rendered from the start and typically draws less CPU power for equivalent visual complexity. However, WebGL's GPU driver may clock the GPU more aggressively — for simple atmospheric scenes (not thousands of polygons), Canvas 2D with thoughtful implementation is entirely adequate and avoids the WebGL overhead.
For a pixel-art campfire with ~50 particles and a scrolling water layer: Canvas 2D is the right choice. Reach for WebGL only if benchmarking proves you need it.
When a mobile device's die temperature exceeds a safety threshold, the OS forces the CPU/GPU to reduce clock speed — sometimes by 50% or more. On a 2020-era mid-range Android phone, sustained GPU load from a Canvas animation running at 60fps can trigger thermal throttling within 5–10 minutes. Once throttled, the device cannot return to full performance until it cools, causing visible animation jank that gets worse over time.
Signs you've caused thermal throttling in testing:
The Android Thermal API (exposed via navigator.deviceThermalState in newer Chrome) allows reading the device thermal state. As of 2025 this API is not widely available in browsers, but the pattern to watch for:
// Indirect thermal detection via frame time monitoring
let consecutiveSlowFrames = 0;
function onFrame(timestamp: number) {
const frameTime = timestamp - lastFrameTime;
if (frameTime > 50) { // more than 50ms = under 20fps
consecutiveSlowFrames++;
if (consecutiveSlowFrames > 10) {
reduceQuality(); // step down particle count or switch to static
}
} else {
consecutiveSlowFrames = Math.max(0, consecutiveSlowFrames - 1);
}
}
Design the atmospheric system with at least three quality tiers:
| Tier | Particles | FPS Target | Use Case |
|---|---|---|---|
| High | 100–200 | 30fps | Desktop, recent mobile |
| Medium | 30–50 | 30fps | Older mobile, initial load |
| Low | 0–10 | 15fps | Thermal throttling detected, low battery |
| Static | 0 | 0fps | prefers-reduced-motion, very low-end |
At 60fps, every frame must complete within 16.67ms total — including JavaScript execution, canvas draw calls, and browser compositing. At 30fps the budget is 33.33ms, which is generous for atmospheric backgrounds.
A typical atmospheric scene breakdown at 30fps:
// Detect slow frames programmatically
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn('Slow frame:', entry.duration, 'ms');
}
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
The biggest cause of frame drops is not the animation itself but other code running during animation: fetch callbacks, event handlers, or Svelte reactivity firing during a frame. Keep animation logic isolated from data-loading code.
This is the highest-impact single optimization. A background animation running while the user is on a different tab burns battery with zero user benefit.
let animationFrameId: number | null = null;
function startAnimation() {
if (animationFrameId !== null) return;
animationFrameId = requestAnimationFrame(tick);
}
function stopAnimation() {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
}
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
stopAnimation();
} else {
startAnimation();
}
});
Most browsers already throttle rAF in background tabs, but relying on browser throttling is not enough — the animation loop still runs, wastes CPU on update logic, and the frame is simply discarded. Explicitly stopping the loop saves all that work.
If the atmospheric background is in a scrollable area and can scroll out of view, use IntersectionObserver to pause it:
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
startAnimation();
} else {
stopAnimation();
}
});
},
{ threshold: 0.01 } // pause when even mostly off-screen
);
observer.observe(canvasElement);
For BibleWeb, the reading view likely shows the atmosphere at the top of the page. Combining visibilitychange + IntersectionObserver covers both tab-switching and scrolling past the scene.
// Only available in Chromium-based browsers
const deviceMemoryGB = (navigator as any).deviceMemory ?? 4; // default to 4GB if unavailable
function getQualityTier(): 'high' | 'medium' | 'low' {
if (deviceMemoryGB <= 1) return 'low';
if (deviceMemoryGB <= 2) return 'medium';
return 'high';
}
The API returns bucketed values: 0.25, 0.5, 1, 2, 4, or 8 (GB). Values under 2GB strongly suggest a budget device. Not available in Firefox or Safari — always provide a safe default.
const cores = navigator.hardwareConcurrency ?? 4;
// Single-core or dual-core devices are likely low-end
if (cores <= 2) reduceParticleCount();
Facebook used hardwareConcurrency + deviceMemory together to bucket users into performance tiers — a practical precedent for this approach.
// Only available in Chromium-based browsers (removed from Firefox/Safari)
async function checkBattery() {
if (!('getBattery' in navigator)) return;
const battery = await (navigator as any).getBattery();
function onBatteryChange() {
if (!battery.charging && battery.level < 0.2) {
// Under 20% and not charging — reduce to low quality
setQualityTier('low');
}
}
battery.addEventListener('levelchange', onBatteryChange);
battery.addEventListener('chargingchange', onBatteryChange);
onBatteryChange(); // check immediately
}
Chromium-only in 2025. Treat as progressive enhancement — the animation should work fine without it.
function detectPerformanceTier(): 'high' | 'medium' | 'low' | 'static' {
// Respect user preference first — always
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
return 'static';
}
const memory = (navigator as any).deviceMemory ?? 4;
const cores = navigator.hardwareConcurrency ?? 4;
if (memory <= 1 || cores <= 2) return 'low';
if (memory <= 2 || cores <= 4) return 'medium';
return 'high';
}
Split the scene into multiple absolutely-positioned canvas elements with different update frequencies:
<!-- Static background — drawn once -->
<canvas id="bg-layer" style="position:absolute;z-index:1"></canvas>
<!-- Slow elements — water ripples at 15fps -->
<canvas id="water-layer" style="position:absolute;z-index:2"></canvas>
<!-- Fast elements — flame, particles at 30fps -->
<canvas id="particle-layer" style="position:absolute;z-index:3"></canvas>
The GPU composites these layers together. The background layer (bg-layer) only needs to be drawn once unless the scene changes, saving all background draw calls every frame.
For sparse particle systems where most of the canvas is empty, only clear and redraw the regions that changed:
function renderParticle(ctx: CanvasRenderingContext2D, p: Particle) {
// Clear only the previous position
ctx.clearRect(p.prevX - p.radius, p.prevY - p.radius,
p.radius * 2 + 1, p.radius * 2 + 1);
// Draw at new position
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fill();
}
For dense particle systems (100+ particles covering most of the canvas), a full clear is typically faster than tracking individual dirty rects.
Move rendering entirely off the main thread to avoid competing with Svelte reactivity and UI logic:
// Main thread
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('/workers/atmosphere.js');
worker.postMessage({ canvas: offscreen }, [offscreen]);
// atmosphere.worker.js
self.onmessage = ({ data: { canvas } }) => {
const ctx = canvas.getContext('2d');
// rAF loop here, isolated from main thread
function tick() {
self.requestAnimationFrame(tick);
render(ctx);
}
tick();
};
OffscreenCanvas is supported in all modern browsers as of 2023. The worker runs on its own thread — main thread jank (e.g., a Svelte store update) cannot drop animation frames.
shadowBlur — it is very expensive, effectively 3x the draw costglobalCompositeOperation changes per frame — batch by composite mode// Slow
ctx.drawImage(sprite, x, y);
// Fast
ctx.drawImage(sprite, Math.round(x), Math.round(y));
// Or: x | 0 (bitwise OR for fast floor)
Benchmarks from the web platform (Canvas 2D, mobile devices, 2024):
| Device Tier | Max Particles @ 60fps | Max Particles @ 30fps |
|---|---|---|
| Desktop (modern) | 10,000+ | 20,000+ |
| High-end mobile (2022+) | 500–1,000 | 1,500–2,000 |
| Mid-range mobile (2019–2021) | 100–300 | 300–600 |
| Budget mobile / older devices | 30–80 | 80–150 |
For BibleWeb's atmospheric scenes (campfire embers, dust motes, water ripples), 50–150 particles total is appropriate for reliable mid-range mobile at 30fps.
| Criterion | Level | Requirement |
|---|---|---|
| 2.2.2 Pause, Stop, Hide | AA | Moving/blinking content lasting 5+ seconds must have a mechanism to pause, stop, or hide it |
| 2.3.1 Three Flashes | A | Content must not flash more than 3 times per second |
| 2.3.3 Animation from Interactions | AAA | Motion animation triggered by interaction can be disabled |
WCAG AA is the legal standard for most jurisdictions (ADA, EN 301 549, AODA). The animated background is ambient, not interaction-triggered, so 2.3.3 (AAA) is the relevant criterion — but implementing it is strongly recommended given the 55+ audience who may have vestibular disorders. 2.2.2 (AA) is legally mandatory: the background must have a pause mechanism if it runs for more than 5 seconds.
CSS:
@media (prefers-reduced-motion: reduce) {
.atmosphere-canvas {
display: none; /* or show static image */
}
.atmosphere-static-fallback {
display: block;
}
}
JavaScript (for Canvas/WebGL animations):
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
function onMotionPreferenceChange() {
if (prefersReducedMotion.matches) {
stopAnimation();
showStaticBackground();
} else {
hideStaticBackground();
startAnimation();
}
}
prefersReducedMotion.addEventListener('change', onMotionPreferenceChange);
onMotionPreferenceChange(); // check on load
For the static fallback, render the first frame of the scene to a PNG at build time, or use a CSS background image that captures the atmosphere visually without motion. The static image should feel warm and intentional — not a broken placeholder. A high-quality still of the campfire scene at dusk is a feature, not a fallback.
Beyond OS-level detection, provide a toggle in the app's settings (or a visible button near the background) to disable animations. This serves users who haven't set the OS preference but still experience motion sensitivity — common in the 55+ demographic.
From documented benchmarks:
For BibleWeb's 50–150 particle target, Canvas 2D is entirely sufficient and simpler to implement.
drawImage() call on a 64×64 sprite: ~0.01–0.05ms on desktop, ~0.1–0.5ms on mobileA 2023 study (thecodingmachine.io) measuring energy consumption of web game engines found that animation-heavy content drew 2–4x more power than static content. Throttling from 60fps to 30fps reduced measured power consumption by approximately 30–40% in their tests — consistent with the GPU idle-time model.
From the PlayCanvas discussion forums (real-world observations):
visibilitychange + IntersectionObservercontain: strict on the canvas wrapper (with explicit dimensions)will-change: transform on the canvas element (not per-particle)prefers-reduced-motion and show static fallbackdeviceMemory + hardwareConcurrency (with Safari/Firefox fallback)shadowBlur — use pre-rendered glow sprites instead