# Webgpu Impl Async Patterns

> Use when structuring a WebGPU frame loop, mapping buffers asynchronously, or loading pipelines without blocking. Prevents CPU-GPU stalls that collapse frame pipelining and the buffer-already-mapped error. Covers the mapAsync map-state lifecycle, onSubmittedWorkDone, createRenderPipelineAsync, the requestAnimationFrame frame loop, and avoiding GPU stalls. Keywords: async, mapAsync, mapState, onSubmittedWorkDone, createRenderPipelineAsync, requestAnimationFrame, frame loop, GPU stall, low frame rate, buffer is already mapped, how do I structure a WebGPU render loop.

- Skill: `impertio-studio/webgpu-impl-async-patterns` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add impertio-studio/webgpu-impl-async-patterns`
- Raw SKILL.md: https://api.skillmd.com/api/skills/impertio-studio/webgpu-impl-async-patterns/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: MIT
- Author: Impertio-Studio (https://skillmd.com/u/impertio-studio)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/impertio-studio/webgpu-impl-async-patterns

---


# WebGPU Async Patterns

WebGPU is asynchronous by design: the GPU runs on its own timeline while JavaScript runs
on the content timeline. This skill structures the frame loop, maps buffers safely, and
loads pipelines without blocking on WebGPU 1.0-stable (Chrome 113+, Safari 26+, Firefox 141+).

## Quick Reference

| API | Returns | Timeline | Use for |
|-----|---------|----------|---------|
| `buffer.mapAsync(mode, offset, size)` | `Promise<undefined>` | resolves when buffer is CPU-owned | CPU access to a `MAP_READ`/`MAP_WRITE` buffer |
| `buffer.mapState` | `"unmapped"` / `"pending"` / `"mapped"` | read synchronously | guard before calling `mapAsync` |
| `buffer.getMappedRange(offset, size)` | `ArrayBuffer` | valid only while `mapped` | read or write the mapped bytes |
| `buffer.unmap()` | `undefined` | returns buffer to `"unmapped"` | release the buffer back to the GPU |
| `queue.onSubmittedWorkDone()` | `Promise<undefined>` | resolves after submitted work completes | one-time GPU completion checkpoint |
| `device.createRenderPipelineAsync(desc)` | `Promise<GPURenderPipeline>` | compiles off the content timeline | load-time pipeline creation |
| `device.createComputePipelineAsync(desc)` | `Promise<GPUComputePipeline>` | compiles off the content timeline | load-time pipeline creation |
| `requestAnimationFrame(cb)` | frame handle | content timeline | the per-frame render driver |

Alignment for `mapAsync` and `getMappedRange`: `offset` MUST be a multiple of 8, `size`
MUST be a multiple of 4.

Promise ordering: `mapAsync` and `onSubmittedWorkDone` promises settle in submission order.

## Decision Tree

```
Creating a pipeline?
├── At app load / level load, no frame is rendering yet
│     -> ALWAYS createRenderPipelineAsync / createComputePipelineAsync.
│        await it before the first frame. Compilation runs off the content
│        timeline, so no shader-compilation hitch.
└── Already inside the frame loop and need a pipeline RIGHT NOW
      -> This is the anti-pattern. NEVER create pipelines per frame.
         Pre-create every pipeline at load time. See webgpu-impl-performance.

Reading GPU data back to the CPU?
├── One-off readback (screenshot, test, debug), no frame loop running
│     -> await queue.onSubmittedWorkDone(), then await stagingBuffer.mapAsync(READ).
│        A stall here is acceptable because no frame is in flight.
└── Continuous readback inside the render loop (picking, histogram, stats)
      -> NEVER await mapAsync or onSubmittedWorkDone in the render path.
         Use a rotating staging buffer; read results one or two frames late.
         See Pattern 4 and references/examples.md.

Where to await?
├── Initialization (adapter, device, pipelines, initial uploads) -> await freely.
└── Inside requestAnimationFrame -> NEVER await GPU completion. Encode and submit
      only. Let the GPU run ahead.
```

## Core Patterns

### Pattern 1: The requestAnimationFrame frame loop

ALWAYS drive rendering with `requestAnimationFrame`. Each frame: update uniforms via
`queue.writeBuffer`, create a FRESH `GPUCommandEncoder`, encode the pass, call
`queue.submit`. NEVER await GPU completion inside the callback.

```js
function frame() {
  // 1. Update CPU-side state, push uniforms.
  queue.writeBuffer(uniformBuffer, 0, uniformData);

  // 2. Fresh encoder every frame. Command buffers are single-use.
  const encoder = device.createCommandEncoder({ label: "frame-encoder" });
  const pass = encoder.beginRenderPass({
    colorAttachments: [{
      view: context.getCurrentTexture().createView(), // fresh view per frame
      loadOp: "clear", storeOp: "store", clearValue: { r: 0, g: 0, b: 0, a: 1 },
    }],
  });
  pass.setPipeline(pipeline);
  pass.setBindGroup(0, bindGroup);
  pass.draw(3);
  pass.end();

  // 3. Submit. Do NOT await anything here.
  queue.submit([encoder.finish()]);

  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
```

NEVER cache `getCurrentTexture()` or its view across frames: the swap-chain rotates
textures. See webgpu-core-pipeline-architecture for pipeline reuse.

### Pattern 2: The mapAsync map-state lifecycle

A `GPUBuffer` moves through three states exposed by `buffer.mapState`:
`"unmapped"` (GPU-owned) -> `"pending"` (after `mapAsync`, before resolve) ->
`"mapped"` (CPU-owned). ALWAYS map only when `mapState === "unmapped"`.

```js
if (buffer.mapState !== "unmapped") return; // already pending or mapped
await buffer.mapAsync(GPUMapMode.READ);     // offset multiple of 8, size multiple of 4
const data = new Float32Array(buffer.getMappedRange().slice(0)); // copy out NOW
buffer.unmap();                              // ArrayBuffer is now detached
```

ALWAYS copy data out of `getMappedRange()` before calling `unmap()`. After `unmap()` the
`ArrayBuffer` is detached and any typed view over it throws `TypeError`.

### Pattern 3: Async pipeline creation at load time

ALWAYS use `createRenderPipelineAsync` / `createComputePipelineAsync` during loading.
They compile shaders off the content timeline, so no compilation hitch reaches a frame.

```js
const [opaquePipeline, shadowPipeline] = await Promise.all([
  device.createRenderPipelineAsync(opaqueDesc),
  device.createRenderPipelineAsync(shadowDesc),
]);
// Only start the frame loop after every pipeline resolves.
requestAnimationFrame(frame);
```

The synchronous `createRenderPipeline` is correct ONLY before the frame loop starts and
for a small number of pipelines. NEVER call it for many heavy shaders during a frame.

### Pattern 4: Non-blocking readback with a rotating staging buffer

For continuous GPU-to-CPU readback, ALWAYS use a ring of staging buffers and read each
result one or two frames late. NEVER await `mapAsync` inside the frame.

```js
const RING = 3;
const staging = Array.from({ length: RING }, (_, i) =>
  device.createBuffer({ label: `readback-${i}`, size: 256,
    usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }));
let frameIndex = 0;

function frame() {
  const writeBuf = staging[frameIndex % RING];
  if (writeBuf.mapState === "unmapped") {
    const encoder = device.createCommandEncoder();
    encoder.copyBufferToBuffer(gpuResult, 0, writeBuf, 0, 256);
    queue.submit([encoder.finish()]);
    // Fire-and-forget map. The promise resolves on a LATER frame.
    writeBuf.mapAsync(GPUMapMode.READ).then(() => {
      const data = new Float32Array(writeBuf.getMappedRange().slice(0));
      writeBuf.unmap();
      consumeReadback(data);
    });
  }
  frameIndex++;
  requestAnimationFrame(frame);
}
```

### Pattern 5: onSubmittedWorkDone as a one-time checkpoint

`queue.onSubmittedWorkDone()` resolves after all previously submitted work completes.
Use it ONLY outside the render loop: a one-off screenshot, a test, or a clean shutdown.

```js
queue.submit([encoder.finish()]);
await queue.onSubmittedWorkDone(); // GPU has finished. Acceptable stall: no frame in flight.
await stagingBuffer.mapAsync(GPUMapMode.READ);
```

NEVER place `await queue.onSubmittedWorkDone()` in a `requestAnimationFrame` callback.

### Pattern 6: Synchronous mapSync in workers (experimental)

Chrome 145 (Jan 2026) adds experimental synchronous `buffer.mapSync()` for Web Workers
behind `--enable-features=WebGPUMapSyncOnWorkers`. It is NOT in the WebGPU 1.0-stable
spec and is unavailable on the main thread, in Safari, and in Firefox. ALWAYS feature-gate
it (`typeof buffer.mapSync === "function"`) and keep `mapAsync` as the only path on
1.0-stable.

## Common Anti-Patterns

1. **Awaiting `mapAsync` or `onSubmittedWorkDone` inside the render loop.** This forces a
   full CPU-GPU synchronization: the CPU idles until the GPU drains every submitted
   command, so the two timelines can no longer overlap and frame rate collapses. Fix: use
   a rotating staging buffer (Pattern 4) and read results one or two frames late.

2. **Calling `mapAsync` on an already-mapped or pending buffer.** WebGPU rejects the
   second call with a `"buffer is already mapped"` validation error, because a buffer can
   only be re-mapped after `unmap()` returns it to `"unmapped"`. Fix: guard with
   `buffer.mapState === "unmapped"` and strictly pair every `mapAsync` with `unmap`.

3. **Using a detached `ArrayBuffer` after `unmap()`.** `unmap()` detaches the buffer
   returned by `getMappedRange()`; any typed view over it then throws `TypeError`. Fix:
   copy the data out (`.slice(0)`) before `unmap()`.

## Critical Warnings

- NEVER `await` `mapAsync` or `onSubmittedWorkDone` inside a `requestAnimationFrame`
  callback. It collapses frame pipelining.
- NEVER call `mapAsync` unless `buffer.mapState === "unmapped"`.
- NEVER read or write a `getMappedRange()` `ArrayBuffer` after `unmap()`; it is detached.
- NEVER reuse a `GPUCommandEncoder` or `GPUCommandBuffer` across frames; both are
  single-use. Create a fresh encoder every frame.
- NEVER recreate pipelines per frame; pre-create them with the async variants at load
  time. See webgpu-impl-performance.
- ALWAYS keep `mapAsync` `offset` a multiple of 8 and `size` a multiple of 4.
- ALWAYS feature-gate `mapSync`; it is experimental and absent on 1.0-stable.

## Reference Files

- `references/methods.md` : `mapAsync` and `mapState` lifecycle, `onSubmittedWorkDone`,
  `createRenderPipelineAsync`, and the frame-loop anatomy.
- `references/examples.md` : working verified code for a `requestAnimationFrame` render
  loop, async pipeline loading, and non-blocking readback.
- `references/anti-patterns.md` : async mistakes with WHY-it-fails explanations.

Related skills: webgpu-syntax-buffers (buffer creation and usage flags),
webgpu-impl-buffer-upload (CPU-to-GPU upload strategies), webgpu-impl-performance
(pipeline reuse, staging rings, bundle caching), webgpu-core-pipeline-architecture
(pipeline objects and the timeline model).

