← Concepts & practices
Concept Language and runtime models

Allocation and memory locality

Same answer, different memory work.

You already fill buffers and loop over grids. Let’s follow a heatmap that refreshes from new readings, where two loops give the same total but, in the lesson’s cache model, very different trips to memory, and reusing the buffer quietly breaks the highlight that shows what changed.

TypeScriptGo One heatmap buffer, two implementations.

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
heatmap.ts
// 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.

TypeScriptReading
heatmap.ts
// 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;
}
GoAlongside
heatmap.go
// 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.

Memory locality

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.

01/ 05
Lay out the heatmap in one buffer

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.

After the second refresh, how many top-row cells does the heatmap highlight as changed?

The heatmap reuses one buffer. To highlight changes it keeps previous = cells.subarray(0, 8) and compares it with the new top row after each refresh.

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.

Live samples

One array, refilled

Made once per analyser and overwritten every frame.

Frozen frame

A copy with its own storage

Made only when someone presses Freeze.

Canvas

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.

waveform.ts
// 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.

ReactAlready in your code
LevelMeter.tsx
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.

Familiar reused storage and loops, and what to watch
Where you’ve seen itWhat’s reused or walkedWhat to watch
getByteTimeDomainData(samples)One array, filled on every callA kept reference shows the next frame
array.subarray(0, 8)The same ArrayBufferWrites show through both ways
A Go subslice, buf[:8]The same backing arrayIt keeps the whole array alive
A pool of reused objectsObjects handed out againAnything still holding one sees the next user’s writes
Nested loops over an image or gridStorage in row orderAn 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.

How a change affects a fresh buffer and a reused buffer
The changeA new buffer per refreshOne reused buffer
Refresh every animation frameA new buffer every frame.One buffer for the life of the view.
Highlight what changed since last timeThe previous frame is still there.Needs a copy of the previous frame.
Add column totalsThe same reads, in a worse order.The same reads, in a worse order.
Send a frame to another componentSafe to hand over.Hand over a copy.
The grid grows to 1,000 × 1,000Four 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