Results come back through a MAP_READ | COPY_DST buffer: copy into it, submit, then await buffer.mapAsync(GPUMapMode.READ), which resolves once the GPU has finished the submitted work that uses it. mapState tracks the steps (the two-argument copyBufferToBuffer() copies a whole buffer):
<script type="module">
const device = await (await navigator.gpu.requestAdapter()).requestDevice();
const { STORAGE, COPY_SRC, COPY_DST, MAP_READ } = GPUBufferUsage;
const prices = device.createBuffer({ size: 24, usage: STORAGE | COPY_SRC | COPY_DST });
device.queue.writeBuffer(prices, 0, new Float32Array([14.99, 39.5, 24, 18.75, 16.2, 21.3]));
const read = device.createBuffer({ label: 'read', size: 24, usage: MAP_READ | COPY_DST });
const encoder = device.createCommandEncoder();
encoder.copyBufferToBuffer(prices, read); // whole buffer
device.queue.submit([encoder.finish()]);
const mapped = read.mapAsync(GPUMapMode.READ);
console.log(`after mapAsync(): ${read.mapState}`);
await mapped;
console.log(`after await: ${read.mapState}`);
const view = new Float32Array(read.getMappedRange());
const copy = [...view].map((p) => p.toFixed(2)); // copy before unmap()
read.unmap();
console.log(`after unmap(): ${read.mapState}, view.length ${view.length}, copy ${copy}`);
</script>after mapAsync(): pending after await: mapped after unmap(): unmapped, view.length 0, copy 14.99,39.50,24.00,18.75,16.20,21.30
unmap() detaches the ArrayBuffer, so its views drop to length 0: copy what you need first. A mapped buffer is off-limits to the GPU, and a second mapAsync() rejected ("Buffer already has an outstanding map pending"). Never await a read-back inside a frame loop (Frame Statistics).