Storage vs Uniform Buffers in WebGPU Compute Shaders: A Decision Guide
Deciding between storage and uniform buffers in WebGPU compute shaders comes down to write access, size limits, and access patterns. This guide compares both and shows a validated read-write example.
29 Apr 2026, 01:11 UTC

Your compute shader needs access to a large array of data, and it needs to write results back. WebGPU gives you two buffer binding types for this — storage and uniform — and picking the wrong one either fails validation outright or quietly costs you performance. The short answer: if the shader writes to the buffer or the data exceeds 64 KiB, you need a storage buffer. If the data is small, read-only, and shared across all invocations (think transform matrices or simulation constants), a uniform buffer is the faster, simpler choice.
This guide walks through the constraints, compares the two options, and shows a working pattern for validating that your storage buffer round-trips correctly.
The constraints that decide for you
Three properties usually settle the decision before you think about performance:
- Write access. Uniform buffers are read-only inside shaders. WGSL will not let you declare a writable binding with
GPUBufferUsage.UNIFORM. If your compute shader produces output, that output lives in a storage buffer (or a storage texture). - Size. Uniform buffers are guaranteed only up to 64 KiB of binding size (the
maxUniformBufferBindingSizelimit; many devices expose exactly this). Storage buffers support much larger bindings — the spec's default limit is 128 MiB, and real devices often allow more. Checkdevice.limits.maxStorageBufferBindingSizeon your adapter. - Access pattern. Uniform buffers are optimized for data every invocation reads identically — the hardware can cache it aggressively. Storage buffers behave more like general memory: flexible, but with higher latency and no assumption of uniform access.
Side-by-side comparison
| Property | Storage Buffer | Uniform Buffer |
|---|---|---|
| Shader access | Read, write, or read-write | Read-only |
| Guaranteed max binding size | 128 MiB (default limit) | 64 KiB |
| Typical use | Large arrays, particle state, output buffers | Matrices, config structs, per-frame constants |
| Usage flags | STORAGE | COPY_SRC | COPY_DST | UNIFORM | COPY_DST |
| WGSL declaration | @binding(0) @group(0) var<storage, read_write> data: array<f32>; | @binding(0) @group(0) var<uniform> params: Params; |
| Performance character | Flexible, higher latency, explicit caching behavior | Fast cached reads when access is uniform across invocations |
Trade-offs that matter in practice
Latency vs. flexibility. Uniform buffers are backed by hardware paths designed for broadcast reads: every thread in a workgroup reading the same address is the best case. Storage buffers can do scattered reads and writes, but you pay for that generality. A common pattern in particle or physics simulations is to put the small read-only config (time step, gravity, bounds) in a uniform buffer and the large mutable state (positions, velocities) in storage buffers. Mixing them is normal — a single bind group can hold both.
Alignment and size rules. Two validation rules trip people up. First, dynamic offsets for uniform bindings must be multiples of minUniformBufferOffsetAlignment (256 bytes on most devices), and storage offsets must satisfy minStorageBufferOffsetAlignment. Second, struct members in WGSL have alignment requirements — vec3<f32> in a uniform address space has 16-byte alignment, so padding your structs deliberately avoids subtle mismatches between your JavaScript Float32Array layout and the WGSL view of the same bytes.
Synchronization. Within a single dispatch, writes from one invocation are not automatically visible to another; you need workgroupBarrier() plus storageBarrier() for cross-invocation visibility inside a workgroup. Across dispatches in the same command encoder, WebGPU inserts synchronization for you between passes — you do not insert manual pipeline barriers as you would in Vulkan.
Concrete implementation: read-write storage buffer with readback
This example doubles an array of floats on the GPU and reads the result back. Run it in a WebGPU-enabled browser (Chrome, Edge, or a recent Firefox) from a page served over HTTPS or localhost. No special permissions are needed beyond navigator.gpu.requestAdapter() succeeding.
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();
const elementCount = 1024;
const byteSize = elementCount * 4; // f32 = 4 bytes
// Large, read-write data: storage buffer.
const storageBuf = device.createBuffer({
size: byteSize,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
});
// Small, read-only config: uniform buffer.
const uniformBuf = device.createBuffer({
size: 16, // one f32 scale factor, padded to 16 bytes
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const input = new Float32Array(elementCount).map((_, i) => i);
device.queue.writeBuffer(storageBuf, 0, input);
device.queue.writeBuffer(uniformBuf, 0, new Float32Array([2.0]));
const module = device.createShaderModule({
code: `
@group(0) @binding(0) var<storage, read_write> data: array<f32>;
@group(0) @binding(1) var<uniform> scale: f32;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
if (id.x < arrayLength(&data)) {
data[id.x] = data[id.x] * scale;
}
}`,
});
const pipeline = device.createComputePipeline({
layout: 'auto',
compute: { module, entryPoint: 'main' },
});
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: storageBuf } },
{ binding: 1, resource: { buffer: uniformBuf } },
],
});
const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.dispatchWorkgroups(Math.ceil(elementCount / 64));
pass.end();
// Readback: copy into a mappable staging buffer.
const staging = device.createBuffer({
size: byteSize,
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
});
encoder.copyBufferToBuffer(storageBuf, 0, staging, 0, byteSize);
device.queue.submit([encoder.finish()]);
await staging.mapAsync(GPUMapMode.READ);
const result = new Float32Array(staging.getMappedRange().slice(0));
staging.unmap();
// CPU reference check
const expected = input.map(v => v * 2.0);
const ok = result.every((v, i) => v === expected[i]);
console.log('Round-trip correct:', ok);Note the roles: the 1024-element array the shader mutates is a storage buffer; the single scale factor every invocation reads is a uniform buffer. The staging buffer exists only because MAP_READ cannot be combined with STORAGE usage on the same buffer — readback always goes through a copy.
How to verify the result
Two checks catch nearly every buffer-type mistake:
- Watch the console for validation messages. Binding a uniform buffer where the shader declares
var<storage>, or exceeding a binding-size limit, produces a validation error at bind group or pipeline creation time and the affected object is invalidated. Silence in the console (with no device loss) is a good first signal. - Compare against a CPU reference. As in the example, compute the expected output in JavaScript and compare element-for-element. A mismatch with no validation errors usually means a layout/alignment bug (your struct padding disagrees with WGSL's rules) or a missing barrier, not a wrong buffer type.
Limitations
Limits vary by device. The 64 KiB uniform and 128 MiB storage figures are the spec's guaranteed minimums; query device.limits rather than assuming headroom. Buffer binding sizes must satisfy alignment rules, and createBuffer sizes should be multiples of 4 bytes at minimum. Finally, the performance guidance here is general — actual caching behavior differs across GPU vendors, so profile with realistic workloads if uniform-vs-storage choice is a hot-path decision.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.