writeBuffer() is already a staging upload: the browser copies your data at once into its own memory and schedules a GPU copy, so your array is free on return, at the cost of that extra copy. To manage staging yourself, fill mapped MAP_WRITE | COPY_SRC buffers, copy on the GPU, and remap them for reuse:
<script type="module">
const device = await (await navigator.gpu.requestAdapter()).requestDevice();
const { STORAGE, COPY_SRC, COPY_DST, MAP_WRITE } = GPUBufferUsage;
const catalog = device.createBuffer({ size: 24, usage: STORAGE | COPY_DST });
const pool = []; // staging buffers ready to reuse
function upload(target, data) {
const staging = pool.pop() ?? device.createBuffer({ size: data.byteLength,
usage: MAP_WRITE | COPY_SRC, mappedAtCreation: true }); // new ones start mapped
new Float32Array(staging.getMappedRange()).set(data);
staging.unmap();
const encoder = device.createCommandEncoder();
encoder.copyBufferToBuffer(staging, 0, target, 0, data.byteLength);
device.queue.submit([encoder.finish()]);
return staging.mapAsync(GPUMapMode.WRITE).then(() => pool.push(staging)); // recycle
}
await upload(catalog, new Float32Array([14.99, 39.5, 24, 18.75, 16.2, 21.3]));
const again = pool[0];
await upload(catalog, new Float32Array([12.74, 33.58, 20.4, 15.94, 13.77, 18.1]));
console.log(`pool: ${pool.length} buffer, reused: ${pool[0] === again}`);
</script>pool: 1 buffer, reused: true
The second upload reused the first staging buffer. Prefer writeBuffer() for small per-frame data, mappedAtCreation for data that exists when the buffer is made (meshes, the catalog), and a staging pool for large streaming uploads you want to decode straight into mapped memory. Either way, batch small writes into big ones.