Projects bibleweb Docs atmosphere-performance-mobile.md

Atmosphere Animation Performance: Mobile & Battery Research

Last modified March 28, 2026

Atmosphere Animation Performance: Mobile & Battery Research

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.


Executive Summary

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.


1. requestAnimationFrame — Throttling and Lifecycle

The Problem

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.

Throttling to 30fps

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.

iOS Safari Low-Power Mode

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.

Cross-Origin iframe Throttling

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.

Firefox resistFingerprinting

When 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.

Centralize the Loop

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.


2. GPU Compositing — will-change and transform Hacks

When GPU Compositing Helps

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 — Use Sparingly

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:

  • Each promoted layer requires a separate texture in GPU memory
  • On 2GB RAM phones, VRAM is extremely limited
  • Too many will-change hints can crash the browser tab by exhausting GPU memory
  • Apply it only immediately before an animation begins, and remove it after
/* 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) — Legacy Hack, Mostly Obsolete

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.

CSS Containment

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.

Properties That Trigger Layout (Avoid These)

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.


3. Battery Drain from Animations

The Core Mechanism

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.

Real-World Impact

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:

  • A Canvas-based atmospheric background drawing at 60fps keeps the GPU active continuously
  • The same scene at 30fps approximately halves GPU active time
  • Pausing the animation entirely when the user is reading a verse is the maximum saving — the GPU returns to idle

Canvas vs WebGL for Power

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.

Practical Battery-Saving Rules

  1. Pause animation when the tab is hidden (see Section 6)
  2. Target 30fps, not 60fps
  3. Do not redraw the canvas if nothing changed (relevant for static frames)
  4. On low battery, further reduce to 15fps or show a static background

4. Mobile Thermal Throttling

What Happens

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:

  • Frame times that are stable for 2–3 minutes, then begin increasing
  • The device becoming warm to the touch
  • Chrome DevTools FPS meter showing a gradual decline over a session

Detection and Adaptive Response

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);
  }
}

Quality Tiers

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

5. Performance Budget — Measuring Frame Time

The 16.6ms (60fps) and 33.3ms (30fps) Budgets

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:

  • Particle update logic: ~2–4ms
  • Canvas clear + background draw: ~1–2ms
  • Particle draw calls (50 particles): ~2–5ms
  • Total target: under 15ms (leave headroom for browser overhead)

Measuring with Chrome DevTools

  1. Open DevTools → Performance panel
  2. Enable the FPS meter (three-dot menu → More tools → Rendering → Frame Rendering Stats)
  3. Record a 5–10 second session while the animation runs
  4. Look for long frames (red bars in the timeline) — anything over 50ms is a dropped frame at 30fps
  5. The Long Animation Frames API (LoAF) in Chrome 116+ provides programmatic access to slow frames
// 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 });

Avoiding Jank Sources

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.


6. Visibility API — Pausing When Not Visible

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.

Page Visibility API

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.

IntersectionObserver for Scroll-Based Activation

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.


7. Progressive Enhancement — Detecting Device Capability

Device Memory API

// 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.

Hardware Concurrency

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.

Battery Status API

// 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.

Composite Detection Function

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';
}

8. Canvas Performance Optimization

Layered Canvases

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.

Dirty Rectangle Updates

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.

OffscreenCanvas with Web Workers

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.

Avoiding Expensive Operations

  • Never use shadowBlur — it is very expensive, effectively 3x the draw cost
  • Avoid globalCompositeOperation changes per frame — batch by composite mode
  • Pre-render sprites to off-screen canvases rather than redrawing from paths each frame
  • Use integer coordinates — floating-point pixel positions force sub-pixel anti-aliasing, which is ~2x slower:
// Slow
ctx.drawImage(sprite, x, y);

// Fast
ctx.drawImage(sprite, Math.round(x), Math.round(y));
// Or: x | 0  (bitwise OR for fast floor)

Practical Particle Count Limits

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.


9. prefers-reduced-motion — Accessibility and Legal Requirements

WCAG Requirements

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.

Implementation

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

What to Show Instead of Animation

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.

In-App Control

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.


10. Real-World Benchmarks

Canvas vs WebGL at Scale

From documented benchmarks:

  • SVG: degrades at ~3,000–5,000 elements — never use for particle systems
  • Canvas 2D: handles 10,000+ simple elements at 60fps on desktop; ~300 on mid-range mobile
  • WebGL: 100,000+ simple elements at 60fps on desktop; ~10,000 on mid-range mobile

For BibleWeb's 50–150 particle target, Canvas 2D is entirely sufficient and simpler to implement.

Frame Time Data

  • 60fps requires all work complete in 16.67ms
  • 30fps allows 33.33ms per frame
  • A single drawImage() call on a 64×64 sprite: ~0.01–0.05ms on desktop, ~0.1–0.5ms on mobile
  • 100 particle draw calls at mobile speeds: ~10–50ms — this is why 30fps and dirty rects matter

Web Game Engine Energy Study

A 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.

PlayCanvas Community Data

From the PlayCanvas discussion forums (real-world observations):

  • A particle system that runs at 60fps on desktop can drop to 15–20fps on mid-range Android
  • The performance gap between desktop and a budget phone is typically 5–10x, not 2x
  • "What runs at 60fps on a MacBook might stutter on a budget Android phone" — design mobile-first

Implementation Checklist for BibleWeb

  • Single shared rAF loop, not per-system loops
  • Target 30fps with delta-time gating (33.33ms interval)
  • Pause via visibilitychange + IntersectionObserver
  • Layered canvases: static bg + water (15fps) + particles (30fps)
  • contain: strict on the canvas wrapper (with explicit dimensions)
  • will-change: transform on the canvas element (not per-particle)
  • OffscreenCanvas + Web Worker for the animation loop
  • Pre-render sprite sheets to off-screen canvas at init time
  • Integer coordinates for all draw calls
  • Detect prefers-reduced-motion and show static fallback
  • Provide in-app animation toggle for accessibility
  • Quality tiers: High (100+ particles) / Medium (30–50) / Low (10) / Static
  • Detect tier via deviceMemory + hardwareConcurrency (with Safari/Firefox fallback)
  • Progressive Battery API check (Chromium only, treat as enhancement)
  • Adaptive quality reduction on sustained slow frames (thermal detection proxy)
  • Never use shadowBlur — use pre-rendered glow sprites instead
  • Test on Chrome DevTools CPU throttle "4x slowdown" (mid-tier mobile simulation)

Sources