Performance Checklist

A Pre-Launch WebGPU Performance Checklist

Before shipping, check the page on the slowest device you support:

  1. Fallback. A missing adapter leads to a WebGL or static path, and device loss rebuilds everything (Robust Recovery).

  2. Startup. Pipelines are built once, asynchronously; the first frame does not wait for dozens of them.

  3. Steady state. No create*() call, await or allocation inside the frame loop.

  4. Timing. GPU time per pass averaged over many frames (Frame Statistics), compared as ratios before and after changes.

  5. Resolution and memory. The canvas follows devicePixelContentBoxSize (Resizing the Context); minified textures have mipmaps; resources are destroy()ed when their scene goes.

  6. Errors and browsers. A clean console in Chrome 1 , Safari 10 and Firefox 555 , since Dawn 1,149 and wgpu differ (Dawn vs wgpu).

A pre-launch checklist that runs live in this browser: fallback, async startup, a steady-state frame loop, timing, resolution and error handlingHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f4ee; font: 14px system-ui, sans-serif; }
  .stage { position: relative; width: 100%; max-width: 600px; }
  .stage canvas { display: block; width: 100%; }
  .stage canvas + canvas { position: absolute; inset: 0; pointer-events: none; }
</style>
<div class="stage">
  <canvas id="view" width="600" height="370"></canvas>
  <canvas id="labels" width="600" height="370"></canvas>
</div>
<script>
const canvas = document.getElementById('view');
const ink = document.getElementById('labels').getContext('2d');
const checks = [];                               // [state: 'ok' | 'warn', title, detail]

function showMessage(text) {                     // check 1 failing: the fallback path itself
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = '#fbeaea'; ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#8a2b2b'; ctx.font = '18px system-ui, sans-serif'; ctx.textAlign = 'center';
  ctx.fillText(text, canvas.width / 2, canvas.height / 2);
}

const code = /* wgsl */ `
@group(0) @binding(0) var<uniform> time: f32;
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> @builtin(position) vec4f {
  let q = vec2f(f32(v & 1), f32(v >> 1));
  let h = 0.5 + 0.3 * sin(time * 2 + f32(i));
  return vec4f(-0.95 + f32(i) * 0.065 + q.x * 0.05, -0.95 + q.y * h * 0.2, 0, 1);
}
@fragment fn fs() -> @location(0) vec4f { return vec4f(0.08, 0.40, 0.75, 1); }`;

async function main() {
  const adapter = await navigator.gpu?.requestAdapter();
  if (!adapter) return showMessage('WebGPU is not available: this is where a WebGL or static path goes');
  checks.push(['ok', 'Fallback', 'navigator.gpu and an adapter checked before anything else']);
  const device = await adapter.requestDevice({ requiredFeatures: adapter.features.has('timestamp-query') ? ['timestamp-query'] : [] });
  let errors = 0, lost = false;
  device.addEventListener('uncapturederror', (e) => { e.preventDefault(); errors++; });
  device.lost.then(() => { lost = true; });
  checks.push(['ok', 'Errors and device loss', 'uncapturederror and device.lost handlers installed first']);

  const context = canvas.getContext('webgpu');
  const format = navigator.gpu.getPreferredCanvasFormat();
  context.configure({ device, format });
  const t0 = performance.now();
  const module = device.createShaderModule({ code });
  const pipeline = await device.createRenderPipelineAsync({ layout: 'auto', primitive: { topology: 'triangle-strip' },
    vertex: { module }, fragment: { module, targets: [{ format }] } });
  checks.push(['ok', 'Startup', `pipeline built once with createRenderPipelineAsync() in ${(performance.now() - t0).toFixed(1)} ms`]);
  // Everything the loop needs is created here, so the loop itself allocates nothing.
  const time = device.createBuffer({ size: 4, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
  const group = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: time } }] });
  const value = new Float32Array(1);
  const colorAttachment = { view: null, clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' };
  const passDescriptor = { colorAttachments: [colorAttachment] };
  checks.push(['ok', 'Steady state', 'no create*(), await or new typed array inside the frame loop']);
  checks.push([device.features.has('timestamp-query') ? 'ok' : 'warn', 'Timing',
    device.features.has('timestamp-query') ? 'timestamp-query available: average GPU time over many frames' : 'no timestamp-query here: profile on a device that has it']);
  const dpr = devicePixelRatio || 1;
  checks.push([dpr === 1 ? 'ok' : 'warn', 'Resolution and memory',
    `devicePixelRatio ${dpr}: ${dpr === 1 ? 'canvas pixels match the screen' : 'size the canvas from devicePixelContentBoxSize'}; mipmap minified textures`]);

  function frame(now) {
    value[0] = now / 1000;
    device.queue.writeBuffer(time, 0, value);
    colorAttachment.view = context.getCurrentTexture().createView();   // the one per-frame object WebGPU requires
    const encoder = device.createCommandEncoder();
    const pass = encoder.beginRenderPass(passDescriptor);
    pass.setPipeline(pipeline); pass.setBindGroup(0, group); pass.draw(4, 30);
    pass.end();
    device.queue.submit([encoder.finish()]);        // one submit per frame
    draw(errors, lost);
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}
function draw(errors, lost) {
  ink.clearRect(0, 0, 600, 370);
  ink.font = 'bold 13px system-ui, sans-serif'; ink.fillStyle = '#222';
  ink.fillText('Checked live on this browser before launch', 12, 22);
  const all = [...checks, [errors || lost ? 'warn' : 'ok', 'Clean console', `${errors} GPU errors, device ${lost ? 'lost' : 'healthy'}; test in Chrome, Safari and Firefox too`]];
  all.forEach(([state, title, detail], i) => {
    const y = 34 + i * 42;
    ink.fillStyle = state === 'ok' ? '#e2f1e5' : '#fbecd5'; ink.beginPath(); ink.roundRect(10, y, 580, 36, 7); ink.fill();
    ink.fillStyle = state === 'ok' ? '#2a8f4d' : '#d08a1c'; ink.beginPath(); ink.arc(28, y + 18, 8, 0, 7); ink.fill();
    ink.fillStyle = '#222'; ink.font = 'bold 12px system-ui, sans-serif'; ink.fillText(title, 44, y + 15);
    ink.fillStyle = '#444'; ink.font = '11px system-ui, sans-serif'; ink.fillText(detail.slice(0, 96), 44, y + 29);
  });
}
main();
</script>