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

Per-frame reallocation versus allocate-once then substream Top path calls bufferData every frame, reallocating GPU memory. Bottom path calls bufferData once with DYNAMIC_DRAW then updates in place with bufferSubData, and shows an interleaved vertex layout. Every frame: bufferData reallocates alloc + copy all alloc + copy all alloc + copy all GPU stalls Allocate once, then substream in place bufferData once bufferSubData bufferSubData no realloc Interleaved vertex: one buffer, one bind x y r g b size x y r g b size stride = 24 bytes, offsets 0 / 8 / 20 STATIC_DRAW for geometry that never changes; DYNAMIC_DRAW for per-frame streams
Allocate the buffer once at its final size, then stream changes into it with bufferSubData. Packing position, colour, and size into one interleaved buffer means one bind and one contiguous upload instead of three.
SVG vs Canvas render-cost crossover Frame cost against element count for a DOM-node SVG chart and a single-element Canvas. element count (×1k)frame costSVG (DOM nodes)Canvas (1 element)
The render-cost crossover underlying typed arrays to gpu buffers: SVG cost climbs with node count while Canvas stays flat, which is why heavy scenes switch engines.

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 bufferDataSTATIC_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

  • bufferData with an array reallocates; bufferSubData never does. The entire optimization hinges on this. If your profile shows per-frame bufferData with a data argument, you are reallocating GPU memory every frame no matter what usage hint you pass.
  • Growing past capacity. bufferSubData cannot extend a buffer; writing past its allocated size throws or is ignored. Allocate for the peak point count up front, or reallocate with bufferData only on the rare frames where capacity must grow.
  • Typed-array views alias their backing buffer. subarray returns a view sharing memory, so passing data.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 offset argument to bufferSubData and vertexAttribPointer is in bytes, so an interleaved layout must multiply float indices by Float32Array.BYTES_PER_ELEMENT. Mixing up bytes and elements silently corrupts attribute reads.
  • Reading back forces a sync. gl.getBufferSubData (WebGL2) or readPixels stalls 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.
Batched vs interleaved layout reads Reflow counts when reads and writes are batched versus interleaved. forced reflowsbatched read4batched write5interleaved read62interleaved write63
Why typed arrays to gpu buffers pays off: batching reads and writes (green/blue) keeps forced reflows low; interleaving them (red) multiplies layout work.

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.