JEPA4Japan · tutorials

Chapter 16: Keep the Front Desk Out of the Kitchen—Worker and GPU Upgrades

4,209 words 20 min read #Canvas#Frontend Engineering#Infinite Canvas#ELI5

Compare main-thread Canvas 2D, OffscreenCanvas workers, and a GPU prototype while handling reordering, backpressure, crashes, device loss, and fallback.

Course progress Course outline 18 of 18 lessons available

Part I: Choose the Surface Before You Draw—Product, Pixels, and Coordinates

  1. 01 Chapter 1: Do Not Draw Yet—Canvas Is Not a Product Architecture available now
  2. 02 Chapter 2: A Sheet of Pixels That Forgets available now
  3. 03 Chapter 3: Turn Drawing into a Replayable Recipe available now
  4. 04 Chapter 4: Four Maps and a Camera available now

Part II: Give the Pixel World a Brain—Model, Scheduling, Input, and Tools

  1. 05 Chapter 5: Give the Pixel World a Registry available now
  2. 06 Chapter 6: Redraw Only When the Light Turns On—Render Scheduling and the React Boundary available now
  3. 07 Chapter 7: Mouse, Touch, and Pen Speak One Language available now
  4. 08 Chapter 8: Find the Big Box Before Inspecting the Edge available now
  5. 09 Chapter 9: Tools Are Traffic Lights, Not a Bag of Booleans available now

Part III: From “It Drags” to “It Is Trustworthy”—Interaction, Text, Assets, and Recovery

  1. 10 Chapter 10: Make the Editor Feel Right available now
  2. 11 Chapter 11: Drawn Text Is Not Editable Text available now
  3. 12 Chapter 12: Borrowed Images Cannot Be Packed Without Rules available now
  4. 13 Chapter 13: Time Machines and Old Boxes available now
  5. 14 Chapter 14: Looking Correct Is Not Being Correct available now

Part IV: Master-Level Decisions—Performance, Workers, GPU, SDKs, Collaboration, and AI

  1. 15 Chapter 15: Do Not Search Ten Thousand Children One by One available now
  2. 16 Chapter 16: Keep the Front Desk Out of the Kitchen—Worker and GPU Upgrades Current lesson
  3. 17 Chapter 17: Build the Car or Buy a Proven Chassis? available now
  4. 18 Chapter 18: People and AI Edit the Same Ledger available now

Start with a game a five-year-old can understand

The one truth in this chapter: a Worker moves work out of the reception queue; a GPU divides suitable work among many small parallel cooks. They do not solve the same problem.

Build a toy restaurant. The child at the front desk must answer the bell, write down orders, and respond to guests. Children in the kitchen chop and plate food. If the receptionist must also chop 10,000 carrots for every order, nobody answers when the next guest rings the bell. That is like the main thread being consumed by drawing or computation. Send the cutting list to the kitchen and the front desk can keep serving guests; that is like a Worker.

Now give the kitchen two kinds of tools: an ordinary knife that cuts in sequence, and a press with many identical small blades that produces hundreds of slices at once, like a GPU. Predict first: if the front desk is slow because it verifies a wrong address character by character, will more carrot blades help? No. Now predict whether a single intricate dessert must benefit from first making a mold, moving ingredients, and washing the machine. No again.

  1. Front desk receives inputAlways respond to guests first
  2. Orders have numbersAn old order cannot cover a new one
  3. The kitchen owns the panExactly one drawing owner
  4. Many small cooks in parallelBatch identical work
  5. Cold food remains when things breakCompatibility Renderer fallback
Decide first: order 41 arrives late after order 42 has already been drawn. Which should appear? 42. A Sequence Number invalidates stale frames.

An upgrade is not a medal. If Chapter 15 proves that Main-Thread Canvas 2D already meets the budget, keep it. Simpler architecture means fewer protocol, copying, dropped-frame, device-loss, and compatibility failures.

Translate the toys into Canvas

Toy restaurantCanvas LabEngineering meaning
Front deskMain ThreadInput, DOM Overlay, Accessibility, and React UI
KitchenDedicated WorkerDocument Diff processing and OffscreenCanvas drawing
Hand the pan to the kitchentransferControlToOffscreen()Transfer Canvas control exactly once
Copy of an orderStructured CloneCopy cloneable messages by default
Hand over the tray itselfTransferableTransfer ownership of ArrayBuffer/ImageBitmap
Order formatMessage ProtocolVersion, sequence, type, payload, and errors
Write only what changedDocument Diff / Command StreamDo not clone the entire Document repeatedly
Order numberSequence NumberDiscard out-of-order responses and Stale Frames
Stop adding orders when the kitchen is fullBackpressureCoalesce replaceable Camera/Preview updates
Press-cut a whole batchGPU BatchingFewer Draw Calls and batched instance data
Cutting moldShaderParallel vertex/fragment program
Ingredient warehouseBuffer / Texture AtlasGPU data and image collection
Bring the result back for inspectionReadbackCPU waits for GPU; expensive in a hot path
Kitchen power failureWorker Crash / Device LossRebuild or fall back to a compatibility Renderer

Where the analogy stops: a Worker does not guarantee speed. It still uses the CPU, and message copying and state synchronization cost time. DOM, input, and the Text Overlay remain on the main thread. After an OffscreenCanvas is transferred, the main thread cannot continue acquiring a Context from that same Canvas. A GPU does not provide infinite cooks: Draw Calls, texture bandwidth, fill rate, Buffer updates, and shared memory on integrated graphics all constrain it. Worker and GPU can be combined or adopted independently.

Kill the wrong intuitions first

  • “Moving it into a Worker automatically removes jank.” If every Pointer Move clones a 50MB Document, Transfer Cost consumes the gain. DOM, input, and the Text Overlay remain on the main thread.
  • “Keeping a Canvas Context on both sides is safer.” A transferred Canvas has one Owner. Dual writes produce undefined frames and lifecycle behavior.
  • “Messages are sent in order, so business work always finishes in order.” Asynchronous decoding, network updates, and rebuilding within the Worker may interleave. The protocol still needs seq, baseRevision, and idempotent handling.
  • “WebGPU unconditionally replaces WebGL2.” The device, adapter, limits, browser/enterprise policy, or driver can refuse it. A WebGL context can also be lost.
  • “GPU Picking is always faster than CPU Geometry.” Picking readback synchronizes CPU and GPU. With a small candidate set, the spatial index from Chapters 8 and 15 is usually simpler.
  • “More Draw Calls are only a few more function calls.” Each call carries CPU/driver overhead. The core of a GPU path is usually Batching and a small number of Buffer updates.
  • “Three.js is a graduation certificate for 2D Canvas.” Follow the 3D branch only when Perspective, Depth, Lighting, Mesh, 3D Camera, Raycasting, and Materials are genuine product requirements.

Production backpack

Prerequisites

Chapter 15 must supply a Trace and budget for the same deterministic scene, identifying whether the bottleneck is main-thread contention, Canvas 2D paint, or Document/Geometry. A Renderer interface already isolates implementations. The Document stores no GPU Buffer or Worker handle. DOM Text, Keyboard, and Screen Reader layers stay on the main thread.

Formal knowledge

Worker / OffscreenCanvas. Worker Ownership means that after transfer, the Worker owns the bitmap, context, resize, and render clock. The main thread keeps InputController and sends normalized Events/Commands to the Worker. transferControlToOffscreen() returns a transferable OffscreenCanvas. ArrayBuffer and ImageBitmap can also be transferred; the original owner must stop using them after sending. Other objects use Structured Clone, whose cost must be measured.

A Message Protocol includes at least protocolVersion, seq, baseRevision, and kind. Initialization sends a Snapshot; later messages send a Command Stream or Document Diff. If the Worker finds a revision gap, it sends resync-required, and the main thread must send a dedicated replace snapshot. The Worker clears old records before installing the complete state. Pretending a Snapshot is upserts + removes: [] leaves already deleted Shapes behind forever. Camera and Pointer Preview are replaceable state; when the queue backs up, retain only the latest. Document Commands cannot be dropped arbitrarily. Backpressure defines maxInFlight, independent acknowledgements, and coalescing rules. A rendering channel may collapse multiple Diffs into the final effect of the latest Snapshot, but the main-thread source-of-truth layer keeps the complete domain Command/History. Sequence Numbers prevent a Stale Frame or response from covering newer state.

Worker-Side rAF schedules a Worker Canvas where supported. Otherwise use main-thread frame messages or a throttled timer as a compatibility path. Background Tabs are throttled or paused. On resume, render from the latest Snapshot/clock instead of catching up through thousands of old frames. Detect a Worker Crash through error/messageerror/heartbeat: terminate the broken Worker, preserve the Document, and rebuild with a new Canvas Host. After repeated failure, fall back to Main-Thread Canvas 2D. transferControlToOffscreen() turns the original <canvas> into a placeholder. The main thread cannot “take back” control or create a 2D context on the same element. Crash fallback must replace it with a new HTMLCanvasElement, then replay the Document.

GPU Renderer. Canvas 2D is the immediate-mode compatibility baseline. WebGL2 and WebGPU require explicit Buffers, Textures, Shaders, and Render Passes. Combine many Shapes with one material into one or a few Draw Calls. Put images in a Texture Atlas while respecting padding, updates, and maximum texture dimensions. After the CPU computes World Transforms and visible instances, write them into a Buffer in batches; Shaders transform vertices and shade pixels in parallel. GPU Picking can encode IDs in an off-screen texture, but Readback synchronizes the pipeline and must be benchmarked against a CPU Spatial Index.

CPU/GPU Synchronization is a common trap. Frequent map/readback, waiting for submission, or updating one tiny Buffer per Shape serializes the pipeline. WebGPU startup performs Feature Detection for navigator.gpu, requestAdapter(), requestDevice(), required limits, and features. The adapter may be null. Listen to device.lost, record its reason, and rebuild resources a limited number of times. For WebGL, listen for context lost/restored, prevent the default behavior, and rebuild. Progressive Enhancement selects an implementation from both Benchmark and capabilities. A Compatibility Renderer must always be able to open the Document and edit/export basic Shapes.

Compare Main Canvas 2D, Offscreen Worker, and GPU Prototype on the same Scene: Startup (initialization/compilation/first frame), Frame Time, Main-Thread Responsiveness, Memory, Transfer Cost, Fallback success rate, and Implementation Complexity (protocols, resources, failure paths, and test cost). If frame time improves while first frame, memory, or input gets worse, do not report only the winner.

Evidence and compatibility (verified 2026-08-29)

Feature-detect OffscreenCanvas, transfer, and Worker rAF for target browsers; see MDN OffscreenCanvas, transferControlToOffscreen(), and DedicatedWorkerGlobalScope requestAnimationFrame. For WebGPU initialization and error handling, follow the W3C WebGPU Specification and MDN WebGPU API. GPUDevice.lost returns device-loss information; recovery requires requesting a new device and rebuilding resources. Production conclusions require evidence from your own Browser Matrix and representative devices. “The API exists” never means “performance is better.”

Engineering increment for this chapter

Starting point: Chapter 15’s SceneRenderer uses main-thread Canvas 2D, and the Trace shows that main-thread render p95 exceeds budget on the representative device in the 100k scene. Finish line: the same Scene/Camera/Document has three Renderers; the Worker has a versioned protocol, backpressure, out-of-order recovery, and crash fallback; the GPU prototype has feature detection, batching, and device-lost fallback.

Add these files and interfaces:

  • src/engine/render/Renderer.ts: compatible init/apply/render/dispose contract;
  • src/engine/worker/protocol.ts, render.worker.ts, and WorkerRenderer.ts: ownership and message protocol;
  • src/engine/gpu/WebGpuRenderer.ts: batched-rectangle prototype;
  • src/engine/render/selectRenderer.ts: feature + benchmark gate;
  • bench/renderers.bench.ts: same-scene report for all three paths;
  • src/engine/worker/__tests__/protocol.test.ts: out-of-order messages, missing Diffs, backpressure, and version mismatch.

The following is a complete minimal main-thread/Worker protocol. Its critical rules are: Commands are never dropped; Camera updates may be coalesced; a revision gap must request a Snapshot; and Canvas transfers exactly once:

// protocol.ts
export type Rect = { id: string; x: number; y: number; w: number; h: number; color: string };
export type MainToWorker =
  | {
      protocolVersion: 1;
      seq: number;
      kind: 'init';
      canvas: OffscreenCanvas;
      width: number;
      height: number;
      dpr: number;
      revision: number;
      snapshot: Rect[];
    }
  | { protocolVersion: 1; seq: number; kind: 'snapshot'; revision: number; snapshot: Rect[] }
  | {
      protocolVersion: 1;
      seq: number;
      kind: 'commands';
      baseRevision: number;
      revision: number;
      upserts: Rect[];
      removes: string[];
    }
  | { protocolVersion: 1; seq: number; kind: 'camera'; x: number; y: number; zoom: number }
  | { protocolVersion: 1; seq: number; kind: 'resize'; width: number; height: number; dpr: number };
export type WorkerToMain =
  | { protocolVersion: 1; kind: 'ready'; ackSeq: number; revision: number }
  | { protocolVersion: 1; kind: 'ack'; ackSeq: number; revision: number; ignored?: boolean }
  | {
      protocolVersion: 1;
      kind: 'frame';
      renderedThroughSeq: number;
      revision: number;
      durationMs: number;
    }
  | { protocolVersion: 1; kind: 'resync-required'; rejectedSeq: number; expectedRevision: number }
  | { protocolVersion: 1; kind: 'fatal'; code: string };

// WorkerRenderer.ts
export class WorkerRenderer {
  private worker: Worker | null = null;
  private seq = 0;
  private sentRevision = 0;
  private pendingCamera: { x: number; y: number; zoom: number } | null = null;
  private pendingAcks = new Set<number>();
  private resyncSeq: number | null = null;
  private readonly maxCameraInFlight = 2;
  private readonly maxProtocolInFlight = 64;
  constructor(
    private readonly state: () => { revision: number; snapshot: Rect[] },
    private readonly fallback: (reason: string) => void,
  ) {}
  start(canvas: HTMLCanvasElement, width: number, height: number, dpr: number) {
    if (!('transferControlToOffscreen' in canvas)) return this.fallback('OFFSCREEN_UNSUPPORTED');
    let worker: Worker;
    try {
      worker = new Worker(new URL('./render.worker.ts', import.meta.url), { type: 'module' });
    } catch {
      return this.fallback('WORKER_CONSTRUCTION_FAILED');
    }
    let offscreen: OffscreenCanvas;
    try {
      offscreen = canvas.transferControlToOffscreen();
    } catch {
      worker.terminate();
      return this.fallback('OFFSCREEN_TRANSFER_FAILED');
    }
    this.worker = worker;
    worker.onmessage = (e: MessageEvent<WorkerToMain>) => this.receive(e.data);
    worker.onerror = () => this.crash('WORKER_ERROR');
    worker.onmessageerror = () => this.crash('MESSAGE_ERROR');
    const state = this.state();
    this.sentRevision = state.revision;
    this.send(
      {
        protocolVersion: 1,
        seq: ++this.seq,
        kind: 'init',
        canvas: offscreen,
        width,
        height,
        dpr,
        revision: state.revision,
        snapshot: state.snapshot,
      },
      [offscreen],
    );
  }
  commands(upserts: Rect[], removes: string[], nextRevision: number) {
    if (!this.worker) return;
    if (this.pendingAcks.size >= this.maxProtocolInFlight)
      throw new Error('RENDER_PROTOCOL_BACKPRESSURE_RETRY');
    if (nextRevision <= this.sentRevision) throw new Error('NON_MONOTONIC_REVISION');
    const message: MainToWorker = {
      protocolVersion: 1,
      seq: ++this.seq,
      kind: 'commands',
      baseRevision: this.sentRevision,
      revision: nextRevision,
      upserts,
      removes,
    };
    this.sentRevision = nextRevision;
    this.send(message);
  }
  camera(x: number, y: number, zoom: number) {
    this.pendingCamera = { x, y, zoom };
    this.flushCamera();
  }
  resize(width: number, height: number, dpr: number) {
    this.send({ protocolVersion: 1, seq: ++this.seq, kind: 'resize', width, height, dpr });
  }
  private send(message: MainToWorker, transfer: Transferable[] = []) {
    if (!this.worker) return;
    this.pendingAcks.add(message.seq);
    this.worker.postMessage(message, transfer);
  }
  private flushCamera() {
    if (!this.pendingCamera || this.pendingAcks.size >= this.maxCameraInFlight) return;
    const latest = this.pendingCamera;
    this.pendingCamera = null;
    this.send({ protocolVersion: 1, seq: ++this.seq, kind: 'camera', ...latest });
  }
  private sendSnapshot() {
    if (this.resyncSeq !== null) return;
    const state = this.state();
    this.sentRevision = state.revision;
    const seq = ++this.seq;
    this.resyncSeq = seq;
    this.send({
      protocolVersion: 1,
      seq,
      kind: 'snapshot',
      revision: state.revision,
      snapshot: state.snapshot,
    });
  }
  private receive(message: WorkerToMain) {
    if (message.protocolVersion !== 1) return this.crash('PROTOCOL_MISMATCH');
    if (message.kind === 'fatal') return this.crash(message.code);
    if (message.kind === 'frame') return;
    if (message.kind === 'resync-required') {
      this.pendingAcks.delete(message.rejectedSeq);
      this.sendSnapshot();
      return;
    }
    this.pendingAcks.delete(message.ackSeq);
    if (message.ackSeq === this.resyncSeq) this.resyncSeq = null;
    this.flushCamera();
  }
  private crash(reason: string) {
    this.worker?.terminate();
    this.worker = null;
    this.fallback(reason);
  }
  dispose() {
    this.worker?.terminate();
    this.worker = null;
  }
}

// render.worker.ts
/// <reference lib="webworker" />
import type { MainToWorker, Rect, WorkerToMain } from './protocol';
const scope: DedicatedWorkerGlobalScope = self as unknown as DedicatedWorkerGlobalScope;
let canvas: OffscreenCanvas | null = null,
  ctx: OffscreenCanvasRenderingContext2D | null = null;
let revision = 0,
  lastSeq = 0,
  camera = { x: 0, y: 0, zoom: 1 },
  scheduled = false;
const shapes = new Map<string, Rect>();
const reply = (message: WorkerToMain) => scope.postMessage(message);
function schedule() {
  if (scheduled) return;
  scheduled = true;
  const callback = () => {
    scheduled = false;
    draw();
  };
  if ('requestAnimationFrame' in scope) scope.requestAnimationFrame(callback);
  else setTimeout(callback, 16);
}
function draw() {
  if (!canvas || !ctx) return;
  const start = performance.now();
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.setTransform(
    camera.zoom,
    0,
    0,
    camera.zoom,
    -camera.x * camera.zoom,
    -camera.y * camera.zoom,
  );
  for (const s of shapes.values()) {
    ctx.fillStyle = s.color;
    ctx.fillRect(s.x, s.y, s.w, s.h);
  }
  reply({
    protocolVersion: 1,
    kind: 'frame',
    renderedThroughSeq: lastSeq,
    revision,
    durationMs: performance.now() - start,
  });
}
scope.onmessage = (event: MessageEvent<MainToWorker>) => {
  const m = event.data;
  if (m.protocolVersion !== 1)
    return reply({ protocolVersion: 1, kind: 'fatal', code: 'PROTOCOL_MISMATCH' });
  if (m.seq <= lastSeq)
    return reply({ protocolVersion: 1, kind: 'ack', ackSeq: m.seq, revision, ignored: true });
  lastSeq = m.seq;
  if (m.kind === 'init') {
    canvas = m.canvas;
    canvas.width = Math.round(m.width * m.dpr);
    canvas.height = Math.round(m.height * m.dpr);
    ctx = canvas.getContext('2d');
    if (!ctx) return reply({ protocolVersion: 1, kind: 'fatal', code: 'NO_2D_CONTEXT' });
    shapes.clear();
    for (const s of m.snapshot) shapes.set(s.id, s);
    revision = m.revision;
    reply({ protocolVersion: 1, kind: 'ready', ackSeq: m.seq, revision });
    schedule();
    return;
  }
  if (!canvas || !ctx) return reply({ protocolVersion: 1, kind: 'fatal', code: 'NOT_INITIALIZED' });
  if (m.kind === 'commands') {
    if (m.baseRevision !== revision)
      return reply({
        protocolVersion: 1,
        kind: 'resync-required',
        rejectedSeq: m.seq,
        expectedRevision: revision,
      });
    for (const id of m.removes) shapes.delete(id);
    for (const s of m.upserts) shapes.set(s.id, s);
    revision = m.revision;
  }
  if (m.kind === 'snapshot') {
    shapes.clear();
    for (const s of m.snapshot) shapes.set(s.id, s);
    revision = m.revision;
  }
  if (m.kind === 'camera') camera = { x: m.x, y: m.y, zoom: m.zoom };
  if (m.kind === 'resize') {
    canvas.width = Math.round(m.width * m.dpr);
    canvas.height = Math.round(m.height * m.dpr);
  }
  reply({ protocolVersion: 1, kind: 'ack', ackSeq: m.seq, revision });
  schedule();
};

Tasks sent with postMessage to the same Worker already have FIFO guarantees. The seq here remains necessary because real pipelines add asynchronous image decoding, network results, crash reconstruction, and injected test conditions, so completion order may not equal submission order. RENDER_PROTOCOL_BACKPRESSURE_RETRY means the upper layer retains the change set and retries after an ack, or collapses render state with the latest Snapshot. It never authorizes deleting a domain Command. ready/ack and frame remain separate so init returning both ready and frame does not decrement the in-flight count twice.

The GPU Prototype does not change the Document. It packs visible rectangles into an instance Buffer. The following complete example shows capability checks, batching, a Shader, and the Device Loss signal. Any failure returns null, allowing the selector to activate the compatibility Renderer:

const shader = `
struct Globals { viewport: vec2f }
struct Instance { rect: vec4f, color: vec4f }
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var<storage, read> items: array<Instance>;
struct Out { @builtin(position) position: vec4f, @location(0) color: vec4f }
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Out {
  let corners = array<vec2f, 6>(vec2f(0,0),vec2f(1,0),vec2f(0,1),vec2f(0,1),vec2f(1,0),vec2f(1,1));
  let p = items[i].rect.xy + corners[v] * items[i].rect.zw;
  var out: Out; out.position = vec4f(p.x / globals.viewport.x * 2.0 - 1.0,
    1.0 - p.y / globals.viewport.y * 2.0, 0.0, 1.0); out.color = items[i].color; return out;
}
@fragment fn fs(input: Out) -> @location(0) vec4f { return input.color; }
`;
export async function createGpuRenderer(
  canvas: HTMLCanvasElement,
  onLost: (reason: string) => void,
) {
  if (!navigator.gpu) return null;
  let device: GPUDevice | undefined,
    disposed = false;
  try {
    const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance' });
    if (!adapter) return null;
    device = await adapter.requestDevice();
    device.lost.then((info) => {
      if (!disposed && info.reason !== 'destroyed')
        onLost(`GPU_LOST:${info.reason}:${info.message}`);
    });
    const context = canvas.getContext('webgpu');
    if (!context) {
      disposed = true;
      device.destroy();
      return null;
    }
    const format = navigator.gpu.getPreferredCanvasFormat();
    context.configure({ device, format, alphaMode: 'premultiplied' });
    device.pushErrorScope('validation');
    const module = device.createShaderModule({ code: shader });
    const pipeline = await device.createRenderPipelineAsync({
      layout: 'auto',
      vertex: { module, entryPoint: 'vs' },
      fragment: {
        module,
        entryPoint: 'fs',
        targets: [
          {
            format,
            blend: {
              color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha' },
              alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' },
            },
          },
        ],
      },
      primitive: { topology: 'triangle-list' },
    });
    const validation = await device.popErrorScope();
    if (validation) throw validation;
    const globals = device.createBuffer({
      size: 16,
      usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
    });
    let items = device.createBuffer({
      size: 32,
      usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
    });
    return {
      render(
        rects: Array<{
          x: number;
          y: number;
          w: number;
          h: number;
          color: [number, number, number, number];
        }>,
        viewport: { width: number; height: number },
      ) {
        if (!(viewport.width > 0 && viewport.height > 0)) throw new Error('INVALID_VIEWPORT');
        const data = new Float32Array(rects.length * 8);
        rects.forEach((r, i) => data.set([r.x, r.y, r.w, r.h, ...r.color], i * 8));
        if (items.size < data.byteLength) {
          items.destroy();
          items = device!.createBuffer({
            size: Math.max(32, Math.ceil(data.byteLength / 256) * 256),
            usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
          });
        }
        device!.queue.writeBuffer(globals, 0, new Float32Array([viewport.width, viewport.height]));
        if (data.byteLength) device!.queue.writeBuffer(items, 0, data);
        const bind = device!.createBindGroup({
          layout: pipeline.getBindGroupLayout(0),
          entries: [
            { binding: 0, resource: { buffer: globals } },
            { binding: 1, resource: { buffer: items } },
          ],
        });
        const encoder = device!.createCommandEncoder(),
          pass = encoder.beginRenderPass({
            colorAttachments: [
              {
                view: context.getCurrentTexture().createView(),
                loadOp: 'clear',
                storeOp: 'store',
                clearValue: [1, 1, 1, 1],
              },
            ],
          });
        pass.setPipeline(pipeline);
        pass.setBindGroup(0, bind);
        pass.draw(6, rects.length);
        pass.end();
        device!.queue.submit([encoder.finish()]);
      },
      dispose() {
        disposed = true;
        globals.destroy();
        items.destroy();
        device!.destroy();
      },
    };
  } catch {
    disposed = true;
    device?.destroy();
    return null;
  }
}

Back to the restaurant: the instance Buffer is one tray of ingredients sent to many small blades at once. Calling draw once per Shape turns it back into carrying plates individually. Viewport reaches the Shader through a Uniform, so the example is not secretly fixed to 1000×1000. The caller must still convert Camera, DPR, and the visible set consistently. Calling dispose() deliberately makes device.lost finish with destroyed; that is not a production failure and must not trigger a rebuild storm. During real Device Loss, the ledger still lives in the main-thread Document, so the system can replace the pan and cook again instead of losing orders.

Protocol tests inject out-of-order messages and revision gaps:

import { expect, test } from 'vitest';

test('an old seq does not replace the new Camera, and a revision gap requests a full sync', () => {
  let lastSeq = 0,
    revision = 7,
    camera = { x: 0, y: 0 };
  const acceptCamera = (m: { seq: number; x: number; y: number }) => {
    if (m.seq <= lastSeq) return;
    lastSeq = m.seq;
    camera = { x: m.x, y: m.y };
  };
  acceptCamera({ seq: 42, x: 20, y: 30 });
  acceptCamera({ seq: 41, x: 1, y: 1 });
  expect(camera).toEqual({ x: 20, y: 30 });
  const applyDiff = (baseRevision: number) =>
    baseRevision === revision ? 'applied' : 'resync-required';
  expect(applyDiff(6)).toBe('resync-required');
  const workerShapes = new Map([
    ['shape-kept', 1],
    ['shape-deleted', 1],
  ]);
  const replaceSnapshot = (ids: string[]) => {
    workerShapes.clear();
    for (const id of ids) workerShapes.set(id, 1);
  };
  replaceSnapshot(['shape-kept']);
  expect([...workerShapes.keys()]).toEqual(['shape-kept']);
});

Run npm run benchmark -- --renderers=main2d,worker2d,webgpu --scene=100k-simple. For the same seed, expect Startup, Frame p50/p95/p99, Main Thread input delay, Memory, Transfer bytes/ms, Fallback tests, and a Complexity checklist. Enable Worker/GPU by default only if they meet a benefit threshold written in advance on representative devices. Run npm exec vitest run src/engine/worker src/engine/gpu; expect out-of-order messages, dropped Diffs, replace Snapshot removing old records, version mismatch, adapter null, intentional destroy, and device lost to follow deterministic resync or fallback paths.

Break it on purpose

Injected failureSymptomEvidenceFixRegression testRecovery
Worker throwsCanvas freezeserror + last ack/revisionTerminate and rebuild with a new <canvas>Crash-on-seq testMain-thread 2D fallback on a new Host
Messages arrive out of orderCamera jumps backwardseq traceDiscard seq <= lastSeq42 followed by 41Preserve latest Camera
Diff is lostShape stays missingbaseRevision gapresync-required SnapshotDrop-revision testRebuild from authoritative Document
WebGPU absentInitialization exceptionFeature reportProgressive selectionnavigator.gpu absentCanvas 2D
Adapter is nullBlank screenAdapter resultReturn null; create no resourcesMocked nullWorker/Main 2D
Device LostGPU frame stopsdevice.lost reasonIgnore intentional destroyed; limited rebuild for real lossInjected-loss + dispose testReplay Document
Integrated GPU is slowerInput/Frame p95 worsensSame-scene A/BDevice-profile gateLow-power profileDefault to 2D
Texture exceeds limitImage missingDevice limits/asset sizePaginate atlas and scaleOversized imagePlaceholder
Page enters backgroundReplays old frames on resumeVisibility + seqDrop stale work; draw only latestHide/show testLatest Snapshot
Main/Worker protocol versions differParse errorHandshake versionReject and refresh/fallbackVersion 1 vs 2Compatibility Renderer

Pass with evidence

Automated evidenceManual evidencePassing condition
Same-scene benchmark for three Renderers; protocol out-of-order/loss/crash and adapter-null/device-lost testsSwitch Renderers on representative hardware; inspect first frame, input response, and fallback noticeAutomated benefit threshold passes; after manual fallback, Document and editability remain intact
Browser Matrix feature report and resource-limit testsExercise integrated graphics, background tab, and GPU-disabled environment once eachEvery environment selects a working Compatibility Renderer
DecisionRequired evidence
Do we need a Worker?Main Thread input delay comes from movable CPU/drawing work, and the gain exceeds message cost
Do we need a GPU?Canvas 2D render is demonstrably the bottleneck, and the batched prototype improves representative devices
Can the Document be lost?Rebuild completely from durable/current Document after Renderer crash
Is messaging reliable?seq, baseRevision, ack, backpressure, and resync tests
What if it is unsupported?Feature Detection and automatic Compatibility Renderer
Is the complexity worthwhile?Startup, Memory, Transfer, failure paths, and maintenance cost all listed
  • Compare Main-Thread 2D, Worker Offscreen 2D, and GPU Prototype on the same Scene.
  • The Worker is the sole Canvas Owner; Commands are never dropped, while Camera/Preview may be coalesced.
  • Out-of-order messages, missing Diffs, Worker Crash, and background recovery have automated tests.
  • WebGPU adapter null, Device Lost, and insufficient limits all fall back without data loss.
  • GPU hot paths contain no synchronous Readback; Picking is benchmarked against CPU Geometry.
  • Keep the simpler Renderer when Benchmark evidence shows no benefit.
  • Evaluate Three.js only when Perspective/Depth/Lighting/Mesh/3D Camera/Raycasting/Materials are real requirements.

Explain it to a five-year-old

Do not say “Worker,” “GPU,” “OffscreenCanvas,” or “Buffer.” Explain why sending work to the kitchen lets the front desk answer the bell sooner but does not necessarily finish food sooner. Why are many small cooks good at cutting identical carrots but unable to help verify a wrong address? Why do the orders survive a kitchen power failure?

A good jargon-free answer

The front desk only welcomes guests and writes orders. Sending time-consuming chopping to the kitchen prevents it from missing the next bell, but copying and carrying orders also takes time, so the food may not finish sooner. Many small cooks can make many identical pieces together, but they cannot help with a wrong address that must be checked step by step. Every order has a newer or older number, so a late old dish cannot cover a new one. The real ledger is not stored in the pan; if the kitchen or large machine breaks, switch back to an ordinary pan and remake the food from the ledger.