01 / The idea
A new buffer per refresh is a fair start.
You’re building a heatmap for a sensor dashboard. Every refresh makes a new Uint32Array for the 64 cells, fills it with readings, and sums the rows for a total.
Each refresh has its own storage, nothing outlives the draw, and nobody can see a half-written
frame. For a heatmap that refreshes now and then, that’s the right code.
Read the first refreshTypeScript · the version this lesson starts from
// The first version: each refresh makes a new buffer and fills it. Nothing outlives the draw.
export function refresh(frame: number): Uint32Array {
const buffer = new Uint32Array(width * height);
fill(buffer, frame);
return buffer;
} Go’s version makes a []uint32 the same way. Both languages meet again at the scans
and the reusable buffer in section 02.
Then two changes land. Someone adds column totals, reading the same buffer down each column: same cells, same answer, and in the lesson’s cache model four times as many misses. Someone else makes refresh reuse one buffer to stop making a new one every frame, and the highlight that marks changed cells never lights up again.
How many buffers you make, the order you read them in, and how long anyone holds on to them are three separate questions. Reading in storage order keeps the next value close to the last. Reusing storage makes fewer buffers, and turns every reference into it into a view of whatever is written next. Intel’s guide to loop optimization puts the first part as a rule of thumb: “If a particular storage location is referenced, then it is likely that nearby memory locations will be referenced soon.”
Section 05 builds an audio waveform view that reuses one sample array every frame, in React and Svelte.
02 / See the shape
Read in storage order, and copy what you keep.
The basic form is access order: one flat buffer, stored row after row, read along the rows or down the columns. In the wild reuses one buffer for every refresh and makes a copy for anything that keeps a frame. At the call site runs both scans, then three refreshes each way.
Both languages print the same totals and counts.
Access order. One flat buffer stored row after row, read in that order or down each column. Both read every cell once.
// One flat buffer, stored row after row: cell (row, column) lives at row * width + column.
export function sumRows(buffer: Uint32Array, visit?: Visit): number {
let total = 0;
for (let row = 0; row < height; row++) {
for (let column = 0; column < width; column++) {
const index = row * width + column;
total += buffer[index];
visit?.(index, buffer[index]);
}
}
return total;
}
// The same cells in a different order: down each column, jumping a whole row each step.
export function sumColumns(buffer: Uint32Array, visit?: Visit): number {
let total = 0;
for (let column = 0; column < width; column++) {
for (let row = 0; row < height; row++) {
const index = row * width + column;
total += buffer[index];
visit?.(index, buffer[index]);
}
}
return total;
} // SumRows reads one flat slice row after row: cell (row, column) lives at row*Width + column.
func SumRows(buffer []uint32) uint64 {
var total uint64
for row := range Height {
for column := range Width {
total += uint64(buffer[row*Width+column])
}
}
return total
}
// SumColumns reads the same cells down each column, jumping a whole row each step.
func SumColumns(buffer []uint32) uint64 {
var total uint64
for column := range Width {
for row := range Height {
total += uint64(buffer[row*Width+column])
}
}
return total
} Reading the TypeScriptTyped arrays, views, and copies
A Uint32Array keeps fixed-width numbers in one ArrayBuffer, so row * width + column is the whole layout. byteLength is 4 bytes per cell, 256 for the grid.
subarray “returns a new typed array on the same ArrayBuffer”, and MDN warns that changes to it “will impact the original
object and vice versa”. slice returns “a copy of a portion of a typed array
into a new typed array object”, which is what keep() uses.
Reading the GoSlices share their backing array
make([]uint32, Width*Height) makes one backing array. The Go blog on slices
says slicing “creates a new slice value that points to the original array”, so Cells[:8] sees every later refresh.
Keep appends into a nil slice, which makes a new backing array. Whether Go puts
a buffer on the stack or the heap is the compiler’s choice, and the Go FAQ says that from
a correctness standpoint “you don’t need to know.”
03 / Follow the reads
Watch the same cells take different trips.
Five steps. The read order comes from running the lesson’s scans, and each read goes through a deliberately tiny cache: four cells to a line, a few lines at a time, least-recently-used lines evicted. Before each step, guess how many reads will miss.
In Try it, switch the order and cache size, then scrub through the reads.
Same cells, different reads.
One flat buffer
- cells
- 64
- bytes
- 256
- cache lines
- 16
One flat buffer. 64 cells in one Uint32Array, stored row after row. In the lesson’s model, every 4 cells share a cache line, so each row spans two lines.
One flat buffer.
The heatmap’s 64 values live in one Uint32Array, row after row. Every four cells share a cache line in the lesson’s model.
Reduced motion: choose a scene to see its completed state.
Read this scene
The heatmap’s 64 values live in one Uint32Array, row after row. Every four cells share a cache line in the lesson’s model.
One flat buffer. 64 cells in one Uint32Array, stored row after row. In the lesson’s model, every 4 cells share a cache line, so each row spans two lines.
Watch restarts when you return. Step through keeps your selected step. Try it starts with a row scan each time you open it.
What order and lifetime buy you
Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at something on this page.
- Reads that stay close
- The row scan reads each line’s four cells in order, so only the first read on each line misses: 16 misses in 64 reads.
- A working set that fits
- A column touches eight lines. With room for eight, the column scan misses 16 times, the same as the rows.
- Fewer buffers made
- Three refreshes make one buffer and 256 bytes instead of three and 768.
- Frames you can keep
keep()copies the frame, so a later refresh can’t change it.- Changes you can measure
- Order and reuse are separate changes to separate code. Each can be tried, and kept or dropped, on its own.
The review words are spatial locality, cache miss, working set, allocation, and buffer lifetime for how long anything may still read a buffer. Section 08 covers what they cost.
04 / Try a decision
A changed-cells highlight that never fires.
The heatmap now reuses its buffer, and highlights top-row cells whose values changed since
the last refresh. The code is in retained.ts, and the lesson’s tests pin what
happens.
This is the cost side of reuse. Who may keep a view, and for how long, is the subject of Ownership, aliasing, and lifetimes, which follows the same kind of kept view in more depth.
05 / Give it a real job
One sample array per frame, and a copy for the frame you freeze.
In a recording app, a waveform view draws the microphone’s signal every animation frame. The analyser fills one sample array again and again, the canvas draws it, and Freeze keeps a copy so the frozen frame stays still while the live array keeps changing.
One array, refilled
Made once per analyser and overwritten every frame.
A copy with its own storage
Made only when someone presses Freeze.
Draws one or the other
It reads the samples during the frame and keeps nothing.
The example leaves out asking for the microphone, audio routing, and resizing the canvas. It avoids a new array every frame; it makes no claim about frame rate or drawing cost.
Build UIs?Every animation loop you write either makes storage each frame or reuses it, and one day something holds on to a frame.
Where it already is in your components
AnalyserNode.getByteTimeDomainData is built for reuse. MDN says it “copies
the current waveform, or time-domain, data into a Uint8Array (unsigned byte array)
passed into it.” The textbook level meter makes that array once in its effect, fills it every
frame, and keeps only the peak level in state.
In Svelte the array stays a plain local. Svelte’s docs say state is proxied “until Svelte finds something other than an array or simple object”, and the meter only needs its number to be reactive.
When you have to own it
Now it’s the waveform view. liveSamples owns the reused array, and its read() result is only good until the next frame. Freeze stores keep(), a slice copy, so the canvas draws a frame that can’t change
under it.
Both versions stop their loop with cancelAnimationFrame when the analyser
changes or the view goes away. MDN notes that requestAnimationFrame calls are paused
in most browsers in background tabs, so a hidden view doesn’t keep drawing.
// The part of an AnalyserNode the view needs.
export type Analyser = { fftSize: number; getByteTimeDomainData(array: Uint8Array): void };
// One sample array for the life of the view. The analyser writes into it every frame.
export function liveSamples(analyser: Analyser) {
const samples = new Uint8Array(analyser.fftSize);
return {
// Overwrites the same array and returns it. Don't hold on to it past this frame.
read(): Uint8Array {
analyser.getByteTimeDomainData(samples);
return samples;
},
// A frame worth keeping: a copy with its own storage.
keep(): Uint8Array {
return samples.slice();
}
};
}
// How far the loudest sample is from silence, which the analyser reports as 128.
export function peak(samples: Uint8Array): number {
let loudest = 0;
for (const sample of samples) loudest = Math.max(loudest, Math.abs(sample - 128));
return loudest;
}
// Draws the samples as one line across the canvas.
export function drawWave(context: CanvasRenderingContext2D, samples: Uint8Array): void {
const { width, height } = context.canvas;
context.clearRect(0, 0, width, height);
context.beginPath();
samples.forEach((sample, index) => {
const x = (index / Math.max(1, samples.length - 1)) * width;
const y = (sample / 255) * height;
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.stroke();
}
A level meter that makes one sample array per analyser, fills it every animation frame, and keeps only the peak in state.
import { useEffect, useState } from 'react';
import { peak } from './waveform';
export function LevelMeter({ analyser }: { analyser: AnalyserNode }) {
const [level, setLevel] = useState(0);
useEffect(() => {
// One array for this analyser, filled again every frame, not a new array per frame.
const samples = new Uint8Array(analyser.fftSize);
let frame = requestAnimationFrame(function tick() {
analyser.getByteTimeDomainData(samples);
setLevel(peak(samples));
frame = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(frame);
}, [analyser]);
return <meter min={0} max={128} value={level} aria-label="Input level" />;
}
06 / Recognize it elsewhere
Anywhere storage is refilled or walked in a loop.
You’ve met all of these. For each one, find what’s reused and who might still be reading it.
| Where you’ve seen it | What’s reused or walked | What to watch |
|---|---|---|
getByteTimeDomainData(samples) | One array, filled on every call | A kept reference shows the next frame |
array.subarray(0, 8) | The same ArrayBuffer | Writes show through both ways |
A Go subslice, buf[:8] | The same backing array | It keeps the whole array alive |
| A pool of reused objects | Objects handed out again | Anything still holding one sees the next user’s writes |
| Nested loops over an image or grid | Storage in row order | An inner loop over rows jumps across memory |
Before reusing storage, find everything that reads it after the next write. Before nesting a loop, check it walks the data in the order it’s stored.
07 / Already in your toolbox
Your platform already documents where storage is shared.
Three places to look. For each one, find what’s reused and what’s copied.
MDN · AnalyserNode.getByteTimeDomainData
A browser API that fills the array you pass instead of returning a new one, and what happens when that array is shorter or longer than the analyser’s window.
Read the reference ↗Go blog · Go slices: usage and internals
How a slice points into a backing array, why a re-slice shares it, and the “possible gotcha” of a small slice keeping a large array in memory, fixed by copying.
Read the post ↗Intel · Loop optimizations where blocks are required
Spatial and temporal locality, cache lines, and why a loop that walks data in its stored order makes better use of what the cache already loaded.
Read the article ↗A useful counterexample: a frame you hand awayWhen a fresh buffer is right
A refresh whose frame is stored, sent to another component, or compared later should get its own storage. Reusing it would only move the copy somewhere easier to forget.
08 / The parts to watch
Allocation, order, and lifetime each tell you less than they seem to.
These are the places it still goes wrong.
Fewer allocations isn’t better locality
Reusing the buffer made two fewer buffers and left the column scan exactly as slow in the model. Order and allocation are different changes.
A view shows a reused buffer’s future, not its past
A subarray or subslice kept across a refresh reads the new values. Copy anything
that must stay as it was.
The cache here is a model
Four cells to a line and a handful of lines keep the counts readable. Real hardware has bigger, layered caches and does more than this model shows, so measure before changing a layout for speed.
A bigger cache can hide a bad order
With eight lines the column scan ties the row scan. On a bigger grid, the column’s working set grows with the height and stops fitting again.
A small Go subslice keeps the whole array alive
The Go blog shows a function that returns a few bytes of a large file and holds the entire file in memory. Copy the part you return.
Stack or heap isn’t your call in Go or TypeScript
Go’s compiler places variables by escape analysis, and JavaScript engines decide for themselves. Reason about how much you make and how long it lives, not where it lands.
09 / Make the call
What would you have to change tomorrow?
Give both refreshes a plausible change and follow the work it creates.
| The change | A new buffer per refresh | One reused buffer |
|---|---|---|
| Refresh every animation frame | A new buffer every frame. | One buffer for the life of the view. |
| Highlight what changed since last time | The previous frame is still there. | Needs a copy of the previous frame. |
| Add column totals | The same reads, in a worse order. | The same reads, in a worse order. |
| Send a frame to another component | Safe to hand over. | Hand over a copy. |
| The grid grows to 1,000 × 1,000 | Four megabytes made per refresh. | Four megabytes made once. |
Reuse a buffer when the same storage is refilled on a hot path and nothing reads it after the next fill. An animation loop is the moment.
Keep a new buffer per refresh when refreshes are rare or frames leave the function. And whichever you pick, loop in the order the data is stored.
The question I’d leave beside the code is: who still reads this buffer after it’s overwritten, and in what order do the reads walk it?
10 / Take the idea with you
Explain the highlight bug without saying “subarray.”
“The kept top row wasn’t the old values. It was a window onto the buffer the next refresh wrote into, so comparing it with the new row always matched. Copying the row fixed it.” In a review, the words are buffer lifetime, allocation, spatial locality, and cache miss.
Before moving on, jot down why the column scan missed more, why the highlight never fired, and one buffer in your own code that something might read after it’s refilled.
Connections to follow nextRelated lessons
- Values and references explains why a kept view and the buffer are one piece of storage.
- Copying, identity, and equality covers the copy a changed-cells check needs to compare against.
- Ownership, aliasing, and lifetimes covers who may keep a view into a reused buffer, and until when.
- CPU and memory profiling is how you find out whether any of this matters in your program.