Under the Hood
Webgpu

Buffers and the explicit memory model

WebGPU makes you allocate GPU memory explicitly and declare up front what a buffer is for — vertex data, a uniform, storage, a copy source — and that declared-once-and-immutable usage is what lets the driver place and validate the buffer optimally instead of guessing at every operation, the same "settle it in advance" philosophy the flagship lesson introduced for pipelines.

Buffers and the explicit memory model

The previous lesson got you a device and a queue and stopped there — nothing was actually allocated on the GPU. A GPUBuffer is where that changes: a block of GPU memory you explicitly ask the device to set aside, of a size you choose, for a purpose you declare before a single byte of data exists in it. That last part — declaring the purpose up front — is the whole lesson. It's the same move as the render pipeline from the flagship lesson: settle something once, in advance, so the driver never has to guess later.

createBuffer: a fixed-size block with a declared purpose

const vertexBuffer = device.createBuffer({
  size: 12 * 4, // 12 floats, 4 bytes each — three vec4 positions, say
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});

Two things happen here, and both are permanent. size fixes how many bytes this buffer holds — it cannot grow or shrink afterward; a differently-sized buffer means a new buffer. And usage is a bitmask of flags OR'd together, declaring every way you intend to use this buffer, and it too is fixed for the buffer's lifetime. The common flags:

FlagMeans the buffer can be...
GPUBufferUsage.VERTEXbound as a vertex buffer in a draw call
GPUBufferUsage.INDEXbound as an index buffer
GPUBufferUsage.UNIFORMbound in a bind group as a uniform (small, read-only in shaders)
GPUBufferUsage.STORAGEbound as a storage buffer (large, readable/writable in shaders — compute lesson)
GPUBufferUsage.COPY_SRCthe source of a copyBufferToBuffer / copyBufferToTexture
GPUBufferUsage.COPY_DSTthe destination of a copy, or of queue.writeBuffer
GPUBufferUsage.MAP_READmappable for the CPU to read from after use
GPUBufferUsage.MAP_WRITEmappable for the CPU to write into before use

You OR together every flag you'll need — GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST is "I'll bind this as vertex data, and I'll also write into it via a copy or writeBuffer." What you can't do is decide later that a vertex buffer should also be a uniform buffer; if you didn't declare UNIFORM at creation, binding it as one is a validation error, full stop.

This is deliberate, and it's the buffer-level instance of the same principle the flagship lesson built the entire API around: declare fixed configuration once, up front, so the browser validates it a single time instead of re-deriving intent on every operation. Because usage is locked in at creation, the driver can place the buffer in whatever memory region is actually optimal for that usage — a uniform buffer might land somewhere small and fast for frequent shader reads, a storage buffer somewhere sized for large read-write access — and every later bind or copy is just a cheap check against the declared flags rather than a runtime guess at what you're trying to do. Contrast this with WebGL, where a buffer's intended use (gl.ARRAY_BUFFER vs gl.ELEMENT_ARRAY_BUFFER, gl.STATIC_DRAW vs gl.DYNAMIC_DRAW) is a soft hint attached at bind time and can silently change from one call to the next — WebGPU turns that hint into a hard, checked contract fixed at creation.

Getting data in: the simple path

For most buffers, most of the time, getting data onto the GPU is one call:

const data = new Float32Array([0, 0.5, 0, 1, -0.5, -0.5, 0, 1, 0.5, -0.5, 0, 1]);
device.queue.writeBuffer(
  vertexBuffer, // destination buffer (needs COPY_DST usage)
  0,            // byte offset into the buffer
  data,         // source: a typed array (or ArrayBuffer)
);

queue.writeBuffer copies bytes from a CPU-side typed array into a GPU buffer, through the queue — the same single channel every other piece of work travels through. The destination buffer must have been created with COPY_DST usage, or this call is rejected. This is the path you reach for by default: it's simple, it goes through the queue like everything else, and the browser handles the actual transfer scheduling.

Getting data in: mapping, when you need direct access

Sometimes you want to write directly into GPU-visible memory rather than handing a copy to the queue — most commonly when filling a buffer once at creation time, before it's ever used:

const buffer = device.createBuffer({
  size: data.byteLength,
  usage: GPUBufferUsage.VERTEX,
  mappedAtCreation: true, // buffer starts out mapped, ready to write into
});

new Float32Array(buffer.getMappedRange()).set(data);
buffer.unmap(); // hands the buffer back to the GPU; you can no longer touch it from JS

mappedAtCreation: true gives you the buffer already mapped, before it's ever touched the GPU proper — no MAP_WRITE usage flag needed for this specific case. getMappedRange() returns a plain ArrayBuffer view over that memory; you wrap it in a typed array and write into it exactly like any other JavaScript array. Calling unmap() is mandatory before the buffer can be used in any GPU operation — it's the boundary that hands the memory back from "CPU can touch this" to "GPU can touch this."

For a buffer you need to write into repeatedly after creation (not just once at the start), the general form is mapAsync:

await buffer.mapAsync(GPUMapMode.WRITE);
new Float32Array(buffer.getMappedRange()).set(newData);
buffer.unmap();

This requires the buffer to have been created with MAP_WRITE usage, and mapAsync is asynchronous — mapping is a real handoff, not an instant pointer cast, because the browser has to ensure the GPU isn't concurrently using that same memory.

The CPU/GPU boundary, and why staging buffers exist

The reason there are two paths at all — writeBuffer and mapping — comes down to a hardware fact: GPU memory generally isn't directly addressable by the CPU the way regular RAM is. On a discrete GPU, it's physically separate memory across the PCIe bus. Even on integrated GPUs where the memory is physically shared, the driver still mediates access rather than exposing raw pointers to JavaScript. So every byte that crosses from your JavaScript typed array to GPU-visible memory has to go through one of these controlled paths — there's no memcpy shortcut.

For large or performance-sensitive transfers, that boundary crossing sometimes goes through a staging buffer — an intermediate buffer created with MAP_WRITE | COPY_SRC usage that you fill from JavaScript, then copy from into a COPY_DST, GPU-local buffer using a command encoder:

// 1. Staging buffer: CPU can map and write it, and it can be a copy source.
const staging = device.createBuffer({
  size: data.byteLength,
  usage: GPUBufferUsage.MAP_WRITE | GPUBufferUsage.COPY_SRC,
  mappedAtCreation: true,
});
new Float32Array(staging.getMappedRange()).set(data);
staging.unmap();

// 2. GPU-local buffer: fast for the GPU to read, never CPU-mapped directly.
const gpuLocal = device.createBuffer({
  size: data.byteLength,
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});

// 3. Copy staging -> gpuLocal (encoder details: lesson 7).
const encoder = device.createCommandEncoder();
encoder.copyBufferToBuffer(staging, 0, gpuLocal, 0, data.byteLength);
device.queue.submit([encoder.finish()]);

The pattern separates "memory the CPU can touch" from "memory that's fastest for the GPU to use," with an explicit copy between them. queue.writeBuffer is effectively this same idea with the staging step hidden inside the browser's implementation; reaching for an explicit staging buffer yourself matters most when you're managing many transfers and want control over exactly when the copy happens.

Getting data out: readback for compute results

Data flows the other direction too — most importantly for reading back the results of a compute shader (lesson 8). You can't map a STORAGE buffer for CPU reading directly; instead you copy its contents into a buffer created specifically for readback, then map that one:

const readback = device.createBuffer({
  size: resultBuffer.byteLength,
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});

const encoder = device.createCommandEncoder();
encoder.copyBufferToBuffer(resultBuffer, 0, readback, 0, resultBuffer.byteLength);
device.queue.submit([encoder.finish()]);

await readback.mapAsync(GPUMapMode.READ);
const result = new Float32Array(readback.getMappedRange().slice(0));
readback.unmap();

Notice resultBuffer itself never gets MAP_READ usage — it stays STORAGE | COPY_SRC, optimized for the GPU's own use, and only the dedicated readback buffer carries MAP_READ. This two-buffer split is the mirror image of the staging pattern for writes: one buffer shaped for the GPU, one buffer shaped for the CPU, with an explicit copy as the only bridge between them.

The map of paths

Nothing here is automatic

Every arrow in that diagram is something you triggered explicitly — a writeBuffer call, a map, a copy through a command encoder. There's no garbage collector for GPU buffers reclaiming memory when you stop referencing them from JavaScript in a timely way, no automatic synchronization stopping you from mapping a buffer the GPU is still reading, no implicit upload the first time you use a resource. You call buffer.destroy() when you're truly done with one; you're responsible for not mapping a buffer that's mid-flight in a submitted command buffer; you decide when a staging copy happens versus letting writeBuffer handle it for you. This is the same "explicit over implicit" trade the device and queue lesson already showed you at the connection level, now applied to memory: WebGPU hands you the actual controls instead of a driver's best guess, and what you get back is a buffer that behaves exactly as declared, every time it's used.

Where this goes next

You can now allocate GPU memory and move data across the CPU/GPU boundary in both directions. What you don't have yet is anything that reads a buffer and turns it into pixels — that requires a pipeline, the object that bakes together shaders, vertex layout, and fixed-function state into the single validated unit WebGPU draws against. Render pipelines is next.

Go deeper

  • MDN — GPUBuffer Full reference for createBuffer's options, mapAsync, getMappedRange, and the exact validation rules around usage flags.
  • WebGPU Fundamentals — Memory Layout Goes deep on how buffer bytes map to WGSL struct layouts, the practical follow-on to 'a buffer is just typed bytes.'
  • WebGPU spec — GPUBufferUsage The normative list of usage flags and exactly which operations each one permits or forbids.
  • web.dev — GPU for the Web A broader look at WebGPU's memory and resource model in the context of real rendering and compute workloads.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Why must usage flags be declared at createBuffer time rather than inferred when the buffer is first bound, and what does the driver get out of that?
  2. What happens if you try to bind a buffer as a uniform when it was only created with VERTEX usage?
  3. Compare queue.writeBuffer to the mappedAtCreation/mapAsync path — when would you reach for each?
  4. Why isn't GPU memory directly writable from JavaScript the way a regular array is, and what does that force every data transfer to do?
  5. Walk through why a staging buffer needs MAP_WRITE | COPY_SRC while the GPU-local buffer it copies into needs COPY_DST plus whatever usage the shader requires.
  6. For compute readback, why does the result buffer stay STORAGE | COPY_SRC instead of also getting MAP_READ directly?
  7. Tie this back to the flagship lesson: what's the WebGL-era equivalent of 'usage is a soft hint,' and how does WebGPU turn it into a hard, checked contract?