Uploading Typed Arrays to GPU Buffers
Your WebGL scatter plot animates, and the frame time spikes every time the data updates because each frame allocates a new GPU buffer and re-uploads every vertex from scratch.
The root cause is treating gl.bufferData — which allocates GPU memory and copies your whole Float32Array into it — as if it were a cheap per-frame write. Allocation is the expensive part, and doing it 60 times a second forces the driver to churn memory and can stall the pipeline waiting for the previous frame’s buffer to be free. This guide is part of WebGL Fundamentals for Data Visualizations, and it is the data-transfer companion to WebGL shader basics for 2D data points: the shader draws the vertices, and this is how you get those vertices onto the GPU without paying the allocation cost every frame.
Diagnostic checklist
Broken vs fixed
// ❌ BROKEN: reallocates and re-uploads the entire buffer every frame.
function drawFrame(gl: WebGLRenderingContext, buffer: WebGLBuffer, positions: number[]): void {
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
// new Float32Array allocates on the CPU; bufferData allocates on the GPU. Every frame.
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
gl.drawArrays(gl.POINTS, 0, positions.length / 2);
}
// ✅ FIXED: allocate once with DYNAMIC_DRAW, reuse one typed array, substream updates.
class PointBuffer {
private buffer: WebGLBuffer;
private data: Float32Array; // persistent CPU-side scratch, reused each frame
constructor(private gl: WebGL2RenderingContext, capacityPoints: number) {
this.data = new Float32Array(capacityPoints * 2); // x,y per point, allocated once
this.buffer = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
// PERF: DYNAMIC_DRAW hints "written often, drawn often" so the driver picks fast storage.
gl.bufferData(gl.ARRAY_BUFFER, this.data.byteLength, gl.DYNAMIC_DRAW); // size-only alloc
}
update(count: number, writeXY: (out: Float32Array) => void): void {
writeXY(this.data); // fill the SAME array in place, no new allocation
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.buffer);
// Upload only the used range; no reallocation, no full re-copy.
this.gl.bufferSubData(this.gl.ARRAY_BUFFER, 0, this.data.subarray(0, count * 2));
}
// A11Y: WebGL pixels are opaque to assistive tech; expose the underlying data as a table.
}
The critical difference is that the fixed version calls gl.bufferData exactly once — with a size argument, not data — to allocate the GPU storage at full capacity, then only ever calls gl.bufferSubData to overwrite bytes in place. bufferData reallocates; bufferSubData does not. Equally important, the CPU-side Float32Array is created once and refilled in place every frame, so you generate zero garbage per frame — a fresh new Float32Array(...) each frame would hand the collector a large object to reclaim 60 times a second, which shows up as periodic GC pauses layered on top of the upload cost.
Step-by-step fix
// Interleaved layout: one buffer, three attributes described by stride and offset.
function setupInterleaved(gl: WebGL2RenderingContext, data: Float32Array): WebGLBuffer {
const buffer = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW);
const F = Float32Array.BYTES_PER_ELEMENT; // 4
const stride = 6 * F; // x,y, r,g,b, size = 6 floats = 24 bytes
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, stride, 0); // position at offset 0
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, stride, 2 * F); // colour at offset 8 bytes
gl.enableVertexAttribArray(2);
gl.vertexAttribPointer(2, 1, gl.FLOAT, false, stride, 5 * F); // size at offset 20 bytes
return buffer;
}
Verification
// Assert the buffer was allocated to full capacity and never shrinks per update.
function assertBufferCapacity(gl: WebGL2RenderingContext, buffer: WebGLBuffer, expectedBytes: number): void {
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
const size: number = gl.getBufferParameter(gl.ARRAY_BUFFER, gl.BUFFER_SIZE);
console.assert(size === expectedBytes, `buffer size ${size} != capacity ${expectedBytes}`);
const usage: number = gl.getBufferParameter(gl.ARRAY_BUFFER, gl.BUFFER_USAGE);
console.assert(usage === gl.DYNAMIC_DRAW, "streaming buffer should be DYNAMIC_DRAW");
}
In DevTools, record a Performance profile during animation and confirm there are no sawtooth GC pauses from per-frame Float32Array allocation, and that the JS heap stays flat. A WebGL-inspector extension should show the buffer allocated once and receiving bufferSubData calls rather than repeated bufferData calls each frame.
Why the usage hint and orphaning matter
The third argument to bufferData — STATIC_DRAW, DYNAMIC_DRAW, or STREAM_DRAW — is a hint, not a command: it tells the driver how you intend to use the buffer so it can place the memory optimally. STATIC_DRAW says “I will upload this once and draw it many times”, so the driver may put it in memory that is fast for the GPU to read but slow to write, which is exactly wrong for a buffer you rewrite every frame. DYNAMIC_DRAW says “I will rewrite this repeatedly and draw it repeatedly”, steering the driver toward storage that is cheap to update. Getting the hint wrong does not break rendering, but it can quietly cost you bandwidth, so match it to your real access pattern.
Orphaning is the subtler technique. When you overwrite a buffer that the GPU may still be reading to draw the previous frame, the driver has two choices: stall the CPU until the GPU finishes (a pipeline bubble), or hand you a fresh block of memory and recycle the old one once the GPU is done. Calling gl.bufferData(target, sameByteLength, usage) with no data — or with a full-size typed array — before your update signals “I no longer care about the old contents”, which lets the driver orphan the previous storage and give you a new block immediately, avoiding the stall. This matters only when you replace the entire buffer each frame; when you use bufferSubData to touch a small changed range, the driver already manages the synchronization for that region and orphaning is unnecessary. The related discipline of cutting draw calls rather than upload cost is covered under instanced rendering, which pairs naturally with a single interleaved buffer.
Edge cases & gotchas
bufferDatawith an array reallocates;bufferSubDatanever does. The entire optimization hinges on this. If your profile shows per-framebufferDatawith a data argument, you are reallocating GPU memory every frame no matter what usage hint you pass.- Growing past capacity.
bufferSubDatacannot extend a buffer; writing past its allocated size throws or is ignored. Allocate for the peak point count up front, or reallocate withbufferDataonly on the rare frames where capacity must grow. - Typed-array views alias their backing buffer.
subarrayreturns a view sharing memory, so passingdata.subarray(0, n)uploads the live bytes with no copy — but mutating the parent array after taking the view changes what you upload. Keep the fill-then-upload order strict. - Byte offsets, not element indices. The
offsetargument tobufferSubDataandvertexAttribPointeris in bytes, so an interleaved layout must multiply float indices byFloat32Array.BYTES_PER_ELEMENT. Mixing up bytes and elements silently corrupts attribute reads. - Reading back forces a sync.
gl.getBufferSubData(WebGL2) orreadPixelsstalls the pipeline by waiting for the GPU to catch up. Never read from a GPU buffer in the render loop; keep the authoritative copy on the CPU. - Interleave versus separate buffers is a tradeoff. Interleaving cuts binds and helps cache locality when all attributes update together, but if only positions change each frame while colours are static, separate buffers let you substream just the positions and leave the colour buffer untouched.
Frequently Asked Questions
What is the difference between bufferData and bufferSubData?
gl.bufferData allocates GPU memory for the buffer and, if you pass a typed array, copies the whole thing in; calling it re-creates the storage. gl.bufferSubData does not allocate — it overwrites a byte range within storage that already exists. So you call bufferData once to size the buffer, then bufferSubData every time you update, which avoids the per-frame reallocation that stalls the GPU.
When should I use STATIC_DRAW versus DYNAMIC_DRAW?
Use gl.STATIC_DRAW for buffers you upload once and draw many times, such as fixed geometry, because the driver can place them in memory that is fast for the GPU to read. Use gl.DYNAMIC_DRAW for buffers you rewrite frequently, such as animated point positions, so the driver picks storage cheap to update. The hint does not change correctness, only the memory placement the driver chooses, so match it to how often you actually rewrite the data.
How do I interleave vertex attributes in one buffer?
Pack the attributes for each vertex contiguously — for example x, y, then r, g, b, then size — into a single Float32Array, upload it to one buffer, and describe each attribute with vertexAttribPointer using a shared stride and a per-attribute byte offset. The stride is the total bytes per vertex and the offset is where each attribute begins. This means one buffer bind and one contiguous upload instead of one per attribute, and it improves cache locality when the attributes are read together.
Related
- WebGL Fundamentals for Data Visualizations — the parent guide to the WebGL rendering pipeline.
- WebGL shader basics for 2D data points — the shaders that consume the vertex buffers uploaded here.
- Reducing draw calls with instanced rendering — cutting draw-call overhead once the data is on the GPU.
- Rendering 100k scatter points without frame drops — the dense-data workload these upload patterns serve.