Projects bibleweb Docs atmosphere-offscreen-canvas.md

Web Workers + OffscreenCanvas Architecture for Background Scene Rendering

Last modified March 29, 2026

Web Workers + OffscreenCanvas Architecture for Background Scene Rendering

Date: 2026-03-29 Status: Research complete Applies to: BibleWeb atmosphere/scene rendering system


Summary

OffscreenCanvas with a dedicated Web Worker is the correct architecture for BibleWeb's animated pixel art backgrounds. It decouples the full render loop (terrain, water, particles, lighting) from the main thread, so Svelte UI interactions never contend with rendering work. As of 2026, browser support is ~96% globally. The main engineering challenges are: structuring the message protocol cleanly, handling the SharedArrayBuffer COOP/COEP requirements if you need shared state, and providing a graceful fallback for the remaining ~4% of users.


1. OffscreenCanvas API

What it is

OffscreenCanvas decouples canvas rendering from the DOM. Once a <canvas> element transfers control via transferControlToOffscreen(), all draw operations happen off-thread — the browser composites the result automatically onto the original element.

// main.ts
const canvasEl = document.querySelector<HTMLCanvasElement>('#scene')!;
const offscreen = canvasEl.transferControlToOffscreen();
const worker = new Worker(new URL('./scene.worker.ts', import.meta.url), {
  type: 'module',
});
worker.postMessage({ type: 'init', canvas: offscreen }, [offscreen]);
// NOTE: offscreen must appear in BOTH the message body AND the transfer list

Browser support (2026)

Browser Support Minimum version
Chrome Full 69
Edge Full 79
Firefox Full 105
Safari Full 17.0
Safari iOS Full 17.0
Samsung Internet Full 10.1
IE None Never

Global coverage: ~96.34% (caniuse.com, March 2026). Safari added full support in 17.0 (Sept 2023) — versions 16.2–16.6 had 2D-only partial support. Firefox required a flag until version 105.

What APIs are available inside a worker

Available:

  • OffscreenCanvasRenderingContext2D (2D canvas API — nearly identical to main-thread version)
  • WebGLRenderingContext / WebGL2RenderingContext
  • requestAnimationFrame() — works fine in a worker context
  • ImageBitmap, ImageData, Path2D
  • fetch(), setTimeout(), setInterval()
  • performance.now()

Unavailable (no DOM access):

  • document, window, navigator (limited)
  • DOM event listeners (input must be messaged in from main thread)
  • drawFocusIfNeeded() — UI-only method, absent from OffscreenCanvasRenderingContext2D
  • CSS variables (must be resolved on main thread and sent as values)

Key constraint

You cannot call getContext() on a canvas element before calling transferControlToOffscreen(). Once transferred, the main thread loses all drawing capability on that element.


2. Web Worker Communication Patterns

The two modes: structured clone vs. transferable

Structured clone (default) copies data. For a 32 MB ArrayBuffer, this can take hundreds of milliseconds. For 500 MB, browser memory doubles during the transfer.

Transferable objects move ownership zero-copy. The sender loses access post-transfer. This is what makes passing OffscreenCanvas to a worker instant regardless of canvas size.

Types that are Transferable:

  • ArrayBuffer
  • ImageBitmap
  • OffscreenCanvas
  • MessagePort
  • AudioData, VideoFrame, MediaSourceHandle
// Zero-copy transfer of an ArrayBuffer
const buffer = new ArrayBuffer(1024 * 1024 * 32); // 32 MB
worker.postMessage({ data: buffer }, [buffer]);
// buffer is now detached — accessing it on main thread throws TypeError

Message serialization costs in practice

As of 2026, simple object messages transmit in 0–1 ms on modern hardware. Transferable objects scale linearly with object count (not byte size). Structured clone sees ongoing speedups from WHATWG-driven improvements (5–40% faster on strings/objects in Node.js, now trickling to browsers).

For BibleWeb's use case the message protocol is thin: scene config objects (passage ID, theme, dimensions, timestamp) are small JSON-like structures that clone in under 1 ms. The only large data that might flow main→worker is resize events (two numbers) or theme changes (a small config object). Worker→main communication should be kept to a minimum — ideally zero for the render loop.

Recommended message contract

// types/scene-worker.ts

export type SceneConfig = {
  passage: string;    // e.g. "john-3"
  theme: 'sunset' | 'night' | 'dawn' | 'storm';
  width: number;
  height: number;
  reducedMotion: boolean;
};

export type WorkerInMessage =
  | { type: 'init'; canvas: OffscreenCanvas; config: SceneConfig }
  | { type: 'resize'; width: number; height: number }
  | { type: 'config'; config: Partial<SceneConfig> }
  | { type: 'pause' }
  | { type: 'resume' }
  | { type: 'destroy' };

export type WorkerOutMessage =
  | { type: 'ready' }
  | { type: 'error'; message: string; stack?: string };

3. Rendering Architecture

System diagram

┌─────────────────────────────────────────────────────┐
│  MAIN THREAD (Svelte)                               │
│                                                     │
│  <AtmosphereCanvas> component                       │
│    ├─ mounts <canvas id="scene">                    │
│    ├─ detects OffscreenCanvas support               │
│    ├─ transfers canvas to SceneWorker               │
│    ├─ sends config on passage/theme change          │
│    └─ handles resize → postMessage({ type:'resize'})│
│                                                     │
│  UI interactions: scroll, tap, notes — NEVER janked │
└────────────────┬────────────────────────────────────┘
                 │  postMessage (Transferable: OffscreenCanvas)
                 │  postMessage (config updates, resize events)
                 ▼
┌─────────────────────────────────────────────────────┐
│  WEB WORKER THREAD (scene.worker.ts)                │
│                                                     │
│  SceneRenderer                                      │
│    ├─ owns OffscreenCanvas + 2D context             │
│    ├─ runs requestAnimationFrame render loop        │
│    ├─ TerrainLayer  (Perlin noise, static geometry) │
│    ├─ WaterLayer    (animated sine waves)           │
│    ├─ SkyLayer      (gradient, day/night cycle)     │
│    ├─ ParticleSystem (50–500 particles)             │
│    ├─ FireLayer     (campfire animation)            │
│    └─ LightingLayer (vignette, point lights)        │
│                                                     │
│  No DOM, no Svelte, no network — pure render math   │
└─────────────────────────────────────────────────────┘

Worker implementation skeleton

// scene.worker.ts
import type { WorkerInMessage, WorkerOutMessage } from './types/scene-worker';

let canvas: OffscreenCanvas | null = null;
let ctx: OffscreenCanvasRenderingContext2D | null = null;
let animFrameId: number | null = null;
let config: SceneConfig | null = null;
let paused = false;

self.onmessage = (event: MessageEvent<WorkerInMessage>) => {
  const msg = event.data;

  switch (msg.type) {
    case 'init':
      canvas = msg.canvas;
      ctx = canvas.getContext('2d')!;
      config = msg.config;
      initLayers(config);
      startLoop();
      postOut({ type: 'ready' });
      break;

    case 'resize':
      if (canvas) {
        canvas.width = msg.width;
        canvas.height = msg.height;
      }
      break;

    case 'config':
      config = { ...config!, ...msg.config };
      applyConfigToLayers(msg.config);
      break;

    case 'pause':
      paused = true;
      if (animFrameId !== null) cancelAnimationFrame(animFrameId);
      break;

    case 'resume':
      paused = false;
      startLoop();
      break;

    case 'destroy':
      if (animFrameId !== null) cancelAnimationFrame(animFrameId);
      self.close();
      break;
  }
};

function startLoop() {
  function frame(timestamp: number) {
    if (!paused && ctx && canvas) {
      tick(timestamp);
      animFrameId = requestAnimationFrame(frame);
    }
  }
  animFrameId = requestAnimationFrame(frame);
}

function postOut(msg: WorkerOutMessage) {
  (self as unknown as Worker).postMessage(msg);
}

Main thread Svelte component

<!-- AtmosphereCanvas.svelte -->
<script lang="ts">
  import { onMount, onDestroy } from 'svelte';
  import type { SceneConfig } from '$lib/types/scene-worker';

  let { config }: { config: SceneConfig } = $props();

  let canvasEl: HTMLCanvasElement;
  let worker: Worker | null = null;

  onMount(() => {
    if ('OffscreenCanvas' in window) {
      const offscreen = canvasEl.transferControlToOffscreen();
      worker = new Worker(new URL('$lib/workers/scene.worker.ts', import.meta.url), {
        type: 'module',
      });
      worker.onmessage = handleWorkerMessage;
      worker.onerror = handleWorkerError;
      worker.postMessage({ type: 'init', canvas: offscreen, config }, [offscreen]);
    } else {
      // Fallback: render on main thread
      startMainThreadRenderer(canvasEl, config);
    }
  });

  $effect(() => {
    // Reactively send config changes to worker
    if (worker && config) {
      worker.postMessage({ type: 'config', config });
    }
  });

  onDestroy(() => {
    worker?.postMessage({ type: 'destroy' });
    worker?.terminate();
  });
</script>

<canvas bind:this={canvasEl} class="scene-bg" />

4. SharedArrayBuffer for Shared State

When to use it

SharedArrayBuffer lets main thread and worker read/write the same memory without copying. For BibleWeb's use case there are two potential applications:

  1. Input pass-through — mouse/touch position for hover effects (e.g., campfire reacts to cursor)
  2. Particle position readback — if the main thread ever needs to know where particles are (for hit testing, tooltips)

For most scene rendering use cases, postMessage is sufficient and simpler. SharedArrayBuffer is worth the complexity only if you have high-frequency data (60+ updates/sec) that postMessage serialization would bottleneck.

Cross-origin isolation requirement

SharedArrayBuffer requires both:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

This has deployment implications: any third-party embedded resource (fonts, images, analytics) must either serve CORS headers (Cross-Origin-Resource-Policy: cross-origin) or be loaded as credentialless. This breaks many standard third-party embeds.

For SvelteKit + Supabase + Stripe, you would need to audit all external resources. This is non-trivial. Recommendation: avoid SharedArrayBuffer unless you hit a concrete postMessage bottleneck.

Pattern if you do use it

// main thread
const sharedBuf = new SharedArrayBuffer(4 * 2); // two Float32 for mouse x,y
const mousePos = new Float32Array(sharedBuf);
worker.postMessage({ type: 'init', canvas: offscreen, sharedBuf }, [offscreen]);

// On mousemove:
document.addEventListener('mousemove', (e) => {
  Atomics.store(mousePos as unknown as Int32Array, 0, e.clientX); // cast for Atomics
});

// Worker reads without waiting for a message:
// Float32Array doesn't work with Atomics directly (Int32 only) — use Int32 and scale
const sharedMouse = new Int32Array(sharedBuf);
const x = Atomics.load(sharedMouse, 0);

Note: Atomics only works with Int8Array, Int16Array, Int32Array, BigInt64Array, and their unsigned variants — not Float32. Store coordinates as integers (e.g., pixels × 1000 for sub-pixel, or just pixel integers).


5. Fallback Strategy

Browser coverage gap

The ~3.7% without OffscreenCanvas support (as of 2026) is primarily:

  • Safari < 17.0 (still in use on older iPhones/iPads that can't update)
  • Older Android browsers
  • IE (irrelevant for this project)

Recommended fallback: main-thread renderer with the same interface

The cleanest approach is a renderer abstraction that both the worker path and the fallback path implement:

// lib/scene/renderer-interface.ts
export interface SceneRenderer {
  resize(width: number, height: number): void;
  updateConfig(config: Partial<SceneConfig>): void;
  pause(): void;
  resume(): void;
  destroy(): void;
}

// lib/scene/worker-renderer.ts — wraps the worker
// lib/scene/main-thread-renderer.ts — runs render loop on main thread directly

// lib/scene/create-renderer.ts
export function createRenderer(canvas: HTMLCanvasElement, config: SceneConfig): SceneRenderer {
  if ('OffscreenCanvas' in window && 'transferControlToOffscreen' in canvas) {
    return new WorkerRenderer(canvas, config);
  }
  return new MainThreadRenderer(canvas, config);
}

The MainThreadRenderer runs the exact same drawing code but calls requestAnimationFrame on the main thread. Users with older browsers get the same visuals — just with slightly higher risk of jank if the device is under load.

Reduced-motion consideration

Always check prefers-reduced-motion and, if set, disable all animation regardless of which renderer path runs:

const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Pass into config: { reducedMotion }
// Worker/renderer renders a static frame and stops the loop

6. TypeScript + Vite + SvelteKit Worker Bundling

The import.meta.url pattern

Vite understands new Worker(new URL('./path', import.meta.url)) and bundles the worker file automatically:

// This is the correct Vite pattern — creates a separate worker bundle
const worker = new Worker(
  new URL('$lib/workers/scene.worker.ts', import.meta.url),
  { type: 'module' }  // Required for ES module workers
);

In development, Vite serves the worker as a module. In production, it bundles it into a separate chunk. This is the safest pattern — no vite plugins needed for basic usage.

SvelteKit path aliasing in workers

SvelteKit's $lib alias works in worker files when bundled via Vite. However, the worker itself runs outside SvelteKit's server context — avoid importing any SvelteKit-specific modules ($app/stores, $app/navigation, etc.) inside a worker file.

// scene.worker.ts — these are OK
import { PerlinNoise } from '$lib/utils/perlin';
import type { SceneConfig } from '$lib/types/scene-worker';

// scene.worker.ts — these will break
// import { page } from '$app/stores'; // NO — SvelteKit store, not available in worker

Known SvelteKit build issues

GitHub issues (#9528, #9600) document cases where SvelteKit builds hang or fail with workers. The root cause is usually circular imports or accidentally importing SvelteKit routing internals. Mitigation: keep worker files isolated with no imports from $app/* or from Svelte components.

Comlink for typed RPC (optional but ergonomic)

Comlink wraps workers to make them feel like regular async function calls:

// vite.config.ts
import { comlink } from 'vite-plugin-comlink';
export default defineConfig({
  plugins: [sveltekit(), comlink()],
  worker: { plugins: () => [comlink()] },
});

// scene.worker.ts — expose functions via Comlink
import { expose } from 'comlink';
expose({
  async init(config: SceneConfig) { /* ... */ },
  async updateConfig(config: Partial<SceneConfig>) { /* ... */ },
});

// main.ts — consume with full TypeScript types
import { ComlinkWorker } from 'vite-plugin-comlink/client';
const renderer = new ComlinkWorker<typeof import('$lib/workers/scene.worker')>(
  new URL('$lib/workers/scene.worker.ts', import.meta.url)
);
await renderer.init(config); // Fully typed, returns Promise

Comlink is most valuable when the worker exposes a complex API. For the scene renderer — which mainly receives fire-and-forget config updates — a raw postMessage switch may be simpler and have less overhead.


7. Performance Measurements

Quantitative data

  • Context creation: OffscreenCanvas context creation is ~50% faster than on-element canvas creation
  • Text rendering operations: 15–45% faster in OffscreenCanvas vs main-thread canvas
  • Simple messages: 0–1 ms round-trip latency on modern hardware
  • Transferable throughput: ~80 kB/ms for large payloads
  • Structured clone overhead: 32 MB ArrayBuffer = hundreds of ms; same data as Transferable = ~0 ms

Qualitative impact (the real win)

The primary benefit is not raw draw speed — it is isolation. When the render loop runs in a worker:

  • Svelte reactivity, store updates, and route transitions never pause the canvas
  • JavaScript garbage collection pauses on the main thread don't drop animation frames
  • Heavy layout recalculates (e.g., Bible verse lists) don't interfere with the campfire flicker
  • The worker's rAF runs at the display refresh rate independently of main-thread busyness

For BibleWeb specifically: a particle system with 200–500 particles is the most expensive operation. On a mid-range mobile device, this could consume 3–8 ms per frame. On the main thread, that directly eats into the 16 ms frame budget for Svelte. In a worker, it is completely isolated.

When OffscreenCanvas is NOT worth it

  • Canvas rendering is trivial (static background image, simple gradient)
  • The app never runs on a device where main thread contention is a real concern
  • The complexity of worker setup outweighs the benefits

For BibleWeb's procedural terrain + animated particles + lighting, the complexity is absolutely justified.


8. Error Handling in Workers

Worker-level errors (uncaught exceptions)

The worker.onerror handler on the main thread fires for uncaught exceptions in the worker. It receives an ErrorEvent with message, filename, lineno, colno.

worker.onerror = (event: ErrorEvent) => {
  console.error('Scene worker crashed:', event.message, event.filename, event.lineno);
  event.preventDefault(); // Prevents the error propagating further
  attemptWorkerRestart();
};

function attemptWorkerRestart() {
  worker?.terminate();
  worker = null;
  // Re-initialize after a short delay
  setTimeout(() => {
    initSceneWorker(canvasEl, config);
  }, 500);
}

Canvas 2D context loss

The 2D canvas API (OffscreenCanvasRenderingContext2D) does not fire a context loss event the way WebGL does. If the browser decides to reclaim GPU resources, the context silently stops drawing.

Detection strategy: periodically check ctx.canvas or wrap draw calls in try/catch, and send a heartbeat message from worker to main thread:

// In worker render loop
if (frameCount % 60 === 0) {
  // Send heartbeat every 60 frames (~1 second at 60fps)
  (self as unknown as Worker).postMessage({ type: 'heartbeat', frameCount });
}

// In main thread — if no heartbeat for 3 seconds, restart worker
let lastHeartbeat = Date.now();
worker.onmessage = (e) => {
  if (e.data.type === 'heartbeat') lastHeartbeat = Date.now();
};
setInterval(() => {
  if (Date.now() - lastHeartbeat > 3000) {
    console.warn('Scene worker unresponsive, restarting');
    attemptWorkerRestart();
  }
}, 1000);

WebGL context loss (if using WebGL renderer)

For WebGL inside a worker, handle webglcontextlost and webglcontextrestored events directly on the canvas:

// In worker (WebGL path)
const canvas = msg.canvas; // OffscreenCanvas
canvas.addEventListener('webglcontextlost', (e) => {
  e.preventDefault(); // Required to allow restoration
  handleContextLost();
}, false);

canvas.addEventListener('webglcontextrestored', () => {
  // Re-initialize all WebGL state, re-upload all textures/buffers
  reinitWebGL();
}, false);

All WebGL resources (buffers, textures, programs) are invalidated on context loss and must be recreated from scratch. This is a documented requirement from the Khronos WebGL spec.

Worker crash recovery

If the worker process itself terminates unexpectedly (OOM, security sandbox kill), onerror may not fire — the worker simply goes silent. The heartbeat pattern above handles this case. Additionally, worker.onmessageerror handles cases where a message fails to deserialize:

worker.onmessageerror = (event) => {
  console.error('Worker message deserialization error:', event);
};

Error propagation from worker to main thread

For non-fatal errors (e.g., a single frame draw failure), use explicit error messages rather than throwing:

// Worker — catch errors at frame level, don't crash the loop
function tick(ts: number) {
  try {
    drawTerrain(ctx, ts);
    drawWater(ctx, ts);
    drawParticles(ctx, ts);
    drawLighting(ctx, ts);
  } catch (err) {
    (self as unknown as Worker).postMessage({
      type: 'error',
      message: (err as Error).message,
      stack: (err as Error).stack,
    });
    // Continue loop — non-fatal
  }
}

Architecture Decision Summary

Concern Decision Rationale
Render thread Web Worker + OffscreenCanvas Isolates 50–500 particle render loop from Svelte UI
Message protocol Raw postMessage (not Comlink) Simple fire-and-forget config updates; Comlink adds dependency with minimal benefit here
Shared state Avoid SharedArrayBuffer COOP/COEP headers complicate Supabase/Stripe embeds
Mouse interactivity Pass via postMessage on mousemove Low-frequency enough that message overhead is negligible
Fallback Main-thread renderer implementing same interface ~4% of users get identical visuals, possibly with jank on load
Error recovery Heartbeat + auto-restart Handles silent worker death; context loss is caught at frame level
TypeScript WorkerInMessage / WorkerOutMessage discriminated unions Full type safety on both sides without Comlink dependency

Sources