Choosing a WebGPU Buffer Upload Path: mappedAtCreation vs writeBuffer vs mapAsync
A decision guide for WebGPU CPU→GPU buffer uploads: when to use mappedAtCreation, writeBuffer, or mapAsync, with a comparison table, trade-off analysis, a ring-buffer implementation for dynamic uniforms, and a runtime validation snippet to confirm zero-copy behavior across browsers.
25 Sept 2026, 15:04 UTC

The Decision: How to Move Data from CPU to GPU in WebGPU
Every WebGPU application needs to upload vertex data, uniforms, or storage buffers from JavaScript to the GPU. The spec offers three distinct APIs for this, each with different performance characteristics, browser support quirks, and usage constraints. Picking the wrong one can mean 2–5× slower uploads, unnecessary memory copies, or code that fails on Safari.
This guide compares the three paths, shows when each wins, and gives you a validation snippet to confirm which zero-copy path your user's browser actually supports.
Quick Comparison
| Method | Best For | Copy Behavior | Latency | Remappable | Browser Quirks |
|---|---|---|---|---|---|
mappedAtCreation: true |
One-time immutable data (vertex/index buffers, static uniforms) | Zero-copy on most backends; single synchronous write | Lowest (no async) | No — mapAsync() throws after unmap() |
Universal support |
device.queue.writeBuffer() |
Small frequent updates (<64 KiB), per-frame uniforms | Two copies: JS → staging buffer → GPU | Low for tiny data; scales poorly | N/A (fire-and-forget) | Universal; no MAP_WRITE flag needed |
buffer.mapAsync(GPUMapMode.WRITE) |
Large buffers (>64 KiB), streaming, variable-size data | Zero-copy on discrete GPUs when usage includes MAP_WRITE | COPY_SRC |
One microtask tick minimum | Yes — can remap after unmap() |
Safari may fall back to staging copy; Dawn requires 8-byte offset/size alignment |
Constraint Checklist Before You Choose
- Data lifetime: Written once, never changed? →
mappedAtCreation - Update size: < 4 KiB every frame? →
writeBuffer - Update size: > 64 KiB or variable? →
mapAsyncwith sub-range mapping - Buffer reuse: Need to rewrite the same buffer later? →
mapAsync(or ring buffer) - Target browsers: Safari 18+ required? Verify
mapAsynczero-copy on Apple Silicon - Alignment: Dawn (Chrome/Edge) requires
offsetandsizemultiples of 8 bytes for sub-rangemapAsync
Trade-off Deep Dive
mappedAtCreation: The Zero-Copy One-Shot
When you create a buffer with mappedAtCreation: true, the implementation hands you a mapped ArrayBuffer immediately. You write into it synchronously, call unmap(), and the data is ready for GPU use. No promise, no staging buffer, no extra syscall on most drivers.
Catch: The buffer can never be mapped again. Calling buffer.mapAsync() after unmap() throws InvalidStateError. Usage flags must include the target usage (e.g., VERTEX, UNIFORM) but not MAP_WRITE — adding it defeats the zero-copy optimization on some backends.
writeBuffer: Convenience for Tiny Updates
device.queue.writeBuffer(buffer, offset, data, dataOffset, size) copies your ArrayBufferView into an internal staging buffer, then schedules a GPU copy. It's simple, synchronous from JS perspective, and works on any buffer with the right usage flag (COPY_DST for the destination).
Cost: Two memory copies (JS → staging → GPU). For a 1 MiB buffer on a discrete GPU, expect 2–5× higher latency than mapAsync. The staging buffer is allocated per call, so frequent large calls churn memory.
Sweet spot: Per-frame uniform buffers under 4 KiB. The overhead is negligible, and you avoid async complexity.
mapAsync: Streaming and Large Transfers
buffer.mapAsync(GPUMapMode.WRITE, offset, size) returns a promise that resolves to an ArrayBufferView mapping the requested range. You write, call unmap(), and the GPU sees the data. On discrete GPUs with MAP_WRITE | COPY_SRC usage, this is a true zero-copy mapping — your JS writes directly into memory the GPU will read.
Requirements:
- Buffer usage must include
GPUBufferUsage.MAP_WRITE - For zero-copy on Dawn: also include
COPY_SRC offsetandsizemust be multiples of 8 bytes (Dawn) or 4 bytes (spec minimum)- Buffer size must be multiple of 4 bytes
Safari note: WebKit on Apple Silicon uses unified memory, so "zero-copy" means something different. mapAsync works but may still involve a driver-internal copy. Test on device.
Concrete Pattern: Ring Buffer for Dynamic Uniforms
For per-frame data larger than 4 KiB (e.g., skinning matrices, particle data), a ring buffer with sub-range mapping avoids full-buffer mapping overhead and sync issues.
// One-time setup
const FRAMES_IN_FLIGHT = 3;
const frameSize = 256 * 1024; // 256 KiB per frame
const ringBuffer = device.createBuffer({
size: frameSize * FRAMES_IN_FLIGHT,
usage: GPUBufferUsage.MAP_WRITE |
GPUBufferUsage.COPY_SRC |
GPUBufferUsage.UNIFORM,
mappedAtCreation: false
});
let frameIndex = 0;
async function uploadFrameData(data) {
const offset = (frameIndex % FRAMES_IN_FLIGHT) * frameSize;
const size = Math.min(data.byteLength, frameSize);
// Align to 8 bytes for Dawn
const alignedOffset = Math.floor(offset / 8) * 8;
const alignedSize = Math.ceil(size / 8) * 8;
const view = await ringBuffer.mapAsync(GPUMapMode.WRITE, alignedOffset, alignedSize);
new Uint8Array(view.buffer, view.byteOffset, size).set(new Uint8Array(data));
ringBuffer.unmap();
// Use as uniform buffer binding with offset/size in bind group
frameIndex++;
return { offset: alignedOffset, size: alignedSize };
}
Why this works: Each frame maps only its slice. The GPU reads from the previous frame's slice while CPU writes the next. No explicit fences needed because WebGPU's command buffer submission order guarantees the GPU finishes frame N before starting frame N+1 on the same queue — but only if you submit one command buffer per frame. For multiple queues or compute, you'd need separate buffers per frame in flight.
Validation: Confirm Zero-Copy Path at Runtime
Run this in your app's initialization (or DevTools console) to verify the browser actually gives you a zero-copy mapping for MAP_WRITE:
async function verifyZeroCopyMapWrite(device) {
const testSize = 1024 * 1024; // 1 MiB
const buffer = device.createBuffer({
size: testSize,
usage: GPUBufferUsage.MAP_WRITE |
GPUBufferUsage.COPY_SRC |
GPUBufferUsage.VERTEX
});
try {
const view = await buffer.mapAsync(GPUMapMode.WRITE);
// Write a known pattern
const u32 = new Uint32Array(view.buffer, view.byteOffset, view.byteLength / 4);
for (let i = 0; i < u32.length; i++) u32[i] = 0xDEADBEEF + i;
buffer.unmap();
// Copy to a MAP_READ buffer to verify GPU sees it
const readback = device.createBuffer({
size: testSize,
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
});
const encoder = device.createCommandEncoder();
encoder.copyBufferToBuffer(buffer, 0, readback, 0, testSize);
device.queue.submit([encoder.finish()]);
await readback.mapAsync(GPUMapMode.READ);
const verify = new Uint32Array(readback.getMappedRange());
let ok = true;
for (let i = 0; i < verify.length; i++) {
if (verify[i] !== 0xDEADBEEF + i) { ok = false; break; }
}
readback.unmap();
console.log(ok ? '✅ Zero-copy MAP_WRITE verified' : '❌ Data mismatch — staging copy?');
return ok;
} catch (e) {
console.warn('mapAsync rejected:', e.message);
return false;
}
}
Expected checks:
- Promise resolves (not rejects) →
MAP_WRITEsupported - Data matches after GPU round-trip → zero-copy path active
- If rejected or data mismatches, check
adapter.limits.maxBufferSizeanddevice.features.has('buffer-map-extended-usages')
Risk: This test allocates 2 MiB of GPU memory and submits a command buffer. Run once at startup, not per frame.
Limitations & Gotchas
- No explicit GPU fences: WebGPU lacks
GPUFence. Ring buffers rely on single-queue submission order. For multi-queue or async compute, allocate separate buffers per frame in flight. - Safari unified memory: On Apple Silicon,
mapAsyncmay not be faster thanwriteBufferfor small data. Benchmark both. - Dawn alignment: Sub-range
mapAsyncoffset/size must be 8-byte aligned. The spec only requires 4 bytes; Dawn is stricter. - MAP_WRITE + MAP_READ together: Adding both flags often disables zero-copy. Use separate buffers for readback.
- Buffer size limits:
adapter.limits.maxBufferSizecan be as low as 256 MiB on some mobile GPUs. Check before allocating ring buffers.
Decision Flowchart (Text Version)
START
├─ Data written once, never updated?
│ └─ YES → mappedAtCreation: true
└─ NO
├─ Update size < 4 KiB every frame?
│ └─ YES → writeBuffer()
└─ NO
├─ Update size > 64 KiB OR variable size?
│ └─ YES → mapAsync(MAP_WRITE) with sub-range
└─ NO (4–64 KiB, regular)
├─ Need to rewrite same buffer later?
│ └─ YES → mapAsync(MAP_WRITE)
└─ NO → writeBuffer() acceptable
Practical Verification Checklist
- Run
verifyZeroCopyMapWrite(device)on target browsers (Chrome, Firefox, Safari TP) - Measure upload latency:
performance.now()aroundmapAsync+write+unmapvswriteBufferfor 1 MiB - Check
adapter.features.has('buffer-map-extended-usages')for sub-range support - Run WebGPU CTS buffer mapping tests:
node scripts/run_cts.js --filter=buffer,map - File bugs if
mapAsyncrejects on valid usage — browser vendors fix these fast
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.