Learn Creative Coding (#132) - Performance Optimization Deep Dive
Learn Creative Coding (#132) - Performance Optimization Deep Dive
At the very end of last time, when we finished building our own little CC framework, I dropped a question almost as a throwaway line: why is this sketch running at eight frames a second when that one flies? And then I ran off before answering it. Allez, that was on purpose - because it's a whole episode on its own, and it's this one :-). Today we go under the hood and figure out why creative code goes slow, and what to actually do about it. Because here's the thing nobody tells you when you start: a sketch that stutters isn't just annoying, it breaks the spell. A flow field at 60fps is hypnotic. The exact same flow field at 9fps is a slideshow of a flow field, and all the magic leaks out.
I want to be careful about how we do this, though, because performance work has a famous way of eating your whole week and making your code uglier for no reason. So we're going to follow one rule above all others, and I'll say it now and keep saying it: measure first, guess never. Almost every slow sketch I've ever fixed was slow for a reason I would not have guessed. The slow part is almost never the part you think. So before we optimise a single line, let's learn to see where the time actually goes.
The frame budget: you have 16 milliseconds, that's it
Let's get the one number that governs everything out of the way first. If you want 60 frames per second, each frame has to be done in 1000/60 milliseconds. That's about 16.6ms. That's your entire budget - clear the screen, update every particle, draw everything, all of it - in less time than it takes to blink about a hundred times over. Go over 16.6ms and the browser misses the repaint, and your framerate drops to 30, then 20, then the slideshow.
So the very first tool isn't a fancy profiler, it's just seeing your framerate honestly. Let me drop a tiny FPS meter into our framework from last episode. No library, just counting.
// a dead-simple FPS meter. count frames, print the count once a second.
let frames = 0;
let lastReport = performance.now();
let fps = 0;
function tickFPS(now) {
frames++;
if (now - lastReport >= 1000) { // a full second has passed
fps = frames; // that's your frames-per-second
frames = 0;
lastReport = now;
}
return fps;
}
Draw that number in the corner of every sketch you make. I mean it - make it part of your CC.sketch() from last episode, always on during development. You want the framerate in your face constantly, because the moment it drops from 60 to 45 you want to notice, right then, while you still remember what you just changed. Finding a slowdown the instant you cause it is ten times easier than hunting it down a week later.
Measure first: where does the time actually go?
The FPS meter tells you that you're slow. It doesn't tell you where. For that we time individual chunks of work, and the browser gives us a beautifully precise clock in performance.now() - it counts milliseconds with decimals, way finer than we need.
// time a chunk of work. wrap the suspect, read the cost in milliseconds.
function measure(label, fn) {
const start = performance.now();
fn(); // run the actual work
const cost = performance.now() - start;
console.log(`${label}: ${cost.toFixed(2)}ms`);
}
// now wrap the suspicious parts of your draw loop:
measure("update", () => updateParticles());
measure("draw", () => drawParticles());
The first time you do this on a slow sketch it's genuinly a bit of a shock. You'll swear the drawing is what's killing you, run the numbers, and find out update is eating 14ms and draw is 0.8ms. That has happened to me more times than I'd like to admit. Your eyes lie about performance. The clock doesn't.
And when you want to go deeper than a couple of console.log lines, the browser's own Performance panel (in the dev tools - press F12, then the Performance tab) is the real weapon. Hit record, let your sketch run for a few seconds, stop. You get a flame chart: every function call, stacked, with the width of each bar being how long it took. The widest bars are your enemy. You don't optimise the clever code, you optimise the wide code. Spend twenty minutes learning to read that chart and you'll save yourself years of guessing.
The number one killer: allocating inside the loop
Right, so what do those wide bars usually turn out to be? Nine times out of ten in creative coding, it's one of two things, and this is the first: you're creating garbage every single frame.
Here's the trap, and it's so easy to fall into because it reads perfectly nicely:
// LOOKS innocent. is secretly a performance disaster.
function updateParticles() {
for (const p of particles) {
const velocity = { x: p.vx, y: p.vy }; // NEW object, every particle, every frame
const gravity = { x: 0, y: 0.1 }; // ANOTHER new object, every frame
p.x += velocity.x;
p.y += velocity.y + gravity.y;
}
}
Every one of those { } makes a brand new object. With 5000 particles at 60fps, that's 600,000 little throwaway objects per second. JavaScript has to clean all of them up, and it does that with the garbage collector - a thing that occasionally freezes your whole program for a few milliseconds to sweep up the mess. That freeze is where your framerate goes to die. It shows up as a horrible periodic hitch - smooth, smooth, smooth, STUTTER, smooth - and now you know exactly what it is.
The fix is almost dull: stop making the garbage. Don't allocate what you can reuse. Pull the constants out, and mutate values instead of wrapping them in fresh objects.
// same behaviour, ZERO garbage. constants live outside, we mutate in place.
const GRAVITY_Y = 0.1; // made ONCE, not per frame
function updateParticles() {
for (const p of particles) {
p.vy += GRAVITY_Y; // just change the number
p.x += p.vx; // no new objects at all
p.y += p.vy;
}
}
Same maths, same result, but nothing gets created and nothing needs cleaning up. The stutter vanishes. This one idea - don't allocate in the hot loop - is probably worth more framerate than everything else in this episode put together. Boring, invisible, enormous.
Object pooling: reuse your particles
Okay but sometimes you genuinely do need new things - particles get born and die all the time, remember the bursts from our sound-reactive work back in episode 11 and again in episode 127. Every onset spawned thirty fresh particles and let them fade out. If you push new ones and splice dead ones constantly, you're back to making and destroying objects non-stop. Same garbage problem, different disguise.
The grown-up answer is an object pool. You make a big pile of particles once, up front, and mark them dead. To "spawn" one you don't create anything - you find a dead one and switch it on. To "kill" one you don't delete it, you just mark it dead again. The pile never grows, never shrinks, never makes garbage.
// a pool: allocate ALL the particles once, reuse forever. no births, no deaths.
const POOL_SIZE = 5000;
const pool = [];
for (let i = 0; i < POOL_SIZE; i++) {
pool.push({ x: 0, y: 0, vx: 0, vy: 0, life: 0, alive: false });
}
// "spawn" = find a dead one and wake it up. creates nothing.
function spawn(x, y, vx, vy) {
for (const p of pool) {
if (!p.alive) { // found a free slot
p.x = x; p.y = y;
p.vx = vx; p.vy = vy;
p.life = 1; p.alive = true;
return p;
}
}
return null; // pool full - just drop the request, no big deal
}
And the update loop just walks the whole pool, skips the dead ones, and flips a particle back to dead when its life runs out - no splice, no array shuffling.
// update the pool: skip dead ones, retire the expired ones by flipping a flag.
function updatePool() {
for (const p of pool) {
if (!p.alive) continue; // dead slots cost almost nothing to skip
p.vy += GRAVITY_Y;
p.x += p.vx;
p.y += p.vy;
p.life -= 0.02;
if (p.life <= 0) p.alive = false; // "kill" = just mark it free again
}
}
That linear scan for a free slot looks a bit dumb, and for really huge pools you'd keep a list of free indices instead - but honestly, for a few thousand particles this is completely fine and you should not overthink it. The win is that after startup, your particle system allocates nothing and frees nothing. The garbage collector has no work to do. Smooth forever. See where this is going? Half of performance is just: make it once, reuse it.
The other big killer: doing N-squared work
The second classic slowdown isn't about garbage at all, it's about the shape of your algorithm. And it sneaks in the moment your particles start caring about each other. Think flocking from episode 18, or the boids we built in episode 50 - every bird needs to look at its neighbours to decide where to go. The obvious way to write that is: for each bird, loop over every other bird and check the distance.
// the NAIVE neighbour check. correct, readable, and quietly O(n squared).
function findNeighbours(birds, radius) {
for (const a of birds) {
a.neighbours = [];
for (const b of birds) { // every bird checks EVERY other bird
if (a === b) continue;
const dx = a.x - b.x, dy = a.y - b.y;
if (dx * dx + dy * dy < radius * radius) { // within range? (no sqrt - episode 13!)
a.neighbours.push(b);
}
}
}
}
(Little aside: notice I compared dx*dx + dy*dy against radius*radius instead of taking a square root to get the real distance. Square roots are slowish, and if you only need to know "is it closer than R", you can compare the squared distances and skip the root entirely. That trick alone is a free speedup any time you're checking distances - it goes all the way back to our trig episode.)
But look at that double loop. With 100 birds that's 100 x 100 = 10,000 checks per frame. Fine. With 1000 birds it's a million checks per frame. With 3000 birds it's nine million, sixty times a second, and your fan spins up like a jet. This is what "O(n squared)" means in practice - double the birds, quadruple the work. It's the reason your flocking sketch was buttery with 200 boids and a slideshow with 2000.
Spatial hashing: only check who's nearby
Here's the insight that fixes it, and it's lovely. A bird can only flock with birds that are close. So why are we checking it against birds on the far side of the screen at all? We should only compare each bird to the handful actually near it. The trick is to chop the screen into a grid of cells, and drop each bird into the cell it sits in. Then to find a bird's neighbours, you only look in its own cell and the ones touching it. This is called a spatial hash, and it's one of my favourite ideas in all of this.
First, every frame, we sort everyone into cells:
// build the grid: bucket every bird into the cell it lands in. cheap - one pass.
const CELL = 50; // cell size ~ your neighbour radius
let grid = new Map();
function key(cx, cy) { return cx + "," + cy; } // a cell's address as a string
function buildGrid(birds) {
grid = new Map(); // (reuse this in a real build - see below)
for (const b of birds) {
const cx = Math.floor(b.x / CELL); // which column
const cy = Math.floor(b.y / CELL); // which row
const k = key(cx, cy);
if (!grid.has(k)) grid.set(k, []);
grid.get(k).push(b); // drop the bird in its cell
}
}
Now the neighbour query only looks at the nine cells around a bird (its own, plus the eight touching it), because nobody further than one cell away can possibly be within the radius:
// find neighbours by checking ONLY the 3x3 block of cells around each bird.
function neighboursOf(b, radius) {
const found = [];
const cx = Math.floor(b.x / CELL);
const cy = Math.floor(b.y / CELL);
for (let ox = -1; ox <= 1; ox++) {
for (let oy = -1; oy <= 1; oy++) { // the 9 cells around us
const cell = grid.get(key(cx + ox, cy + oy));
if (!cell) continue;
for (const other of cell) { // only a few birds live in each cell
if (other === b) continue;
const dx = b.x - other.x, dy = b.y - other.y;
if (dx * dx + dy * dy < radius * radius) found.push(other);
}
}
}
return found;
}
And that's the whole thing. Instead of every bird checking all 3000 others, each bird checks maybe a dozen birds in its neighbourhood. The work stops growing with the square of the flock and grows roughly linearly instead. I've watched a flocking sketch jump from 12fps to a locked 60 with this single change, and no bird behaves any differently - it's the exact same flocking, just stops asking pointless questions. (One honest note: buildGrid makes a fresh Map and arrays every frame, which is - yep - allocation in the loop. In a polished build you'd clear and reuse the same structures. I wrote it fresh here so it reads clearly, but you know the fix now.)
Draw less, and change state less
So far we've been fixing the update. Now the draw. The canvas is faster than people fear, but it has one real cost you can feel: changing its state. Every time you set fillStyle, or save()/restore(), or switch a blend mode, the browser does a little bit of setup work. Do that thousands of times a frame and it adds up. So if you've got 5000 particles and you set the colour before drawing each one, you're paying that cost 5000 times - even when half of them are the same colour.
The fix is to batch by state: group everything that shares a colour, set the colour once, then draw all of them.
// SLOW: set the colour once PER particle, even when colours repeat.
for (const p of particles) {
ctx.fillStyle = p.color; // a state change every single time
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fill();
}
// FASTER: group by colour, set fillStyle ONCE per group, one path for the whole batch.
const byColour = new Map();
for (const p of particles) {
if (!byColour.has(p.color)) byColour.set(p.color, []);
byColour.get(p.color).push(p);
}
for (const [colour, group] of byColour) {
ctx.fillStyle = colour; // set ONCE for the whole group
ctx.beginPath();
for (const p of group) { // add every circle to ONE path...
ctx.moveTo(p.x + p.r, p.y);
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
}
ctx.fill(); // ...and fill them all in a single call
}
Same picture, far fewer state changes and far fewer fill() calls. This is the software-canvas cousin of the instancing idea we met in episode 70, where the whole trick was "draw a thousand things in one go instead of a thousand separate goes". Different tool, identical wisdom: the computer is happiest doing one big job, not a thousand tiny ones.
Cache what doesn't change
Here's a lazy-genius move that people miss constantly. Is part of your scene static? A background, a grid, a logo, a texture you drew once and never touch again? Then stop redrawing it sixty times a second! Draw it once onto a separate offscreen canvas, and every frame just stamp that finished image down in a single operation.
// pre-render the static stuff ONCE onto an offscreen canvas...
const bgCanvas = document.createElement("canvas");
bgCanvas.width = 800; bgCanvas.height = 800;
const bgCtx = bgCanvas.getContext("2d");
drawExpensiveBackground(bgCtx); // all the slow static drawing - happens one time
// ...then every frame, blit the whole thing in ONE cheap call.
function draw() {
ctx.drawImage(bgCanvas, 0, 0); // one operation replaces hundreds of draw calls
drawMovingStuff(ctx); // only the animated bits do real work each frame
}
If your background was a thousand hand-drawn hatch lines (hello episode 27), that thousand-line drawing used to happen every frame. Now it happens once, ever, and each frame pays for a single image stamp. It's one of the biggest wins available for the least cleverness, and I feel a bit smug every time I use it :-).
When one thread isn't enough: OffscreenCanvas and Workers
Last one, and it's the heavy artillery, so I'll keep it to the idea rather than a giant build. Everything we've done so far runs on the browser's main thread - the same single thread that also handles your clicks, your scrolling, the whole page. If your per-frame computation is genuinly enormous (a big physics sim, a fluid solver, a fractal), it can hog that thread and make the whole page feel stuck.
The escape hatch is a Web Worker: a completely separate thread where you can do heavy computation without freezing the page. And its beautiful partner is OffscreenCanvas - a canvas that a worker is allowed to draw to directly, off on its own thread. You hand the canvas over to the worker once, and from then on the worker can render on its own while the main thread stays free and responsive.
// MAIN thread: hand a canvas off to a worker to render on a separate thread.
const canvas = document.querySelector("#c");
const offscreen = canvas.transferControlToOffscreen(); // give the canvas away
const worker = new Worker("render-worker.js");
worker.postMessage({ canvas: offscreen }, [offscreen]); // ship it over to the worker
// render-worker.js runs on its OWN thread - the main page never stutters:
// onmessage = (e) => {
// const ctx = e.data.canvas.getContext("2d");
// function loop() { heavySimulation(ctx); requestAnimationFrame(loop); }
// loop();
// };
This is real power, and like all real power it comes with strings - a worker can't touch the DOM, and getting data in and out of it takes a little ceremony. So it's overkill for a normal particle sketch, and I would not reach for it until you've measured and confirmed that raw computation on the main thread is genuinly your bottleneck. But it's good to know the ceiling is much higher than one thread. When we start thinking about really big simulations, this is the door we walk through.
Your exercise: make a slow sketch fast
Here's the brief, and it's the most practical one I've given you. Go find one of your own sketches that stutters - you've definately got one, we all do - and don't touch a line of it yet. First, bolt on the FPS meter from the top of this episode so you can see the number. Then wrap your update and your draw in the measure() timer and find out, with the clock, not your gut, which one is eating the frame.
Only then start fixing, and fix in this order because it's roughly the order of payoff: kill any allocation in the hot loop first (look for { } and [ ] and new inside your per-frame code), pool your particles if they're being born and killed constantly, and if the slow part is neighbour-checking, drop in the spatial hash. Re-measure after every single change. Write the framerate down before and after. The goal isn't just a faster sketch - it's to feel, in your own hands, how much faster measured work is than guessed work. Get one sketch from a stutter to a locked 60, and you'll never be scared of a slow one again.
Where this is heading
Getting a sketch to run fast quietly changes what you're allowed to dream up. Once you know how to find and kill a bottleneck, the ceiling on your ideas lifts - suddenly a hundred thousand particles isn't a fantasy, it's a Tuesday. And two threads pull straight out of today. One is that all this measuring and tuning is a pain to do by hand, poking at console.log - wouldn't it be nicer to have real knobs and readouts, a little instrument panel for your sketch that shows you the framerate and lets you tweak things live? We started that idea with the auto-sliders last episode, and it's about to become a proper thing. The other thread is bigger: even with every trick from today, the 2D canvas has a hard ceiling, because it does most of its work on the processor and not the graphics chip. There's a whole newer way of talking to the GPU that's built from the ground up for exactly the massively-parallel work that makes our simulations crawl - and getting there is one of the real summits of this series. Both of those are coming. Get one slow sketch flying first, with the clock in your hand, because everything ahead assumes you can look at a stutter and know where to cut :-).
't Komt erop neer...
- The whole game is one number: 16.6ms per frame for 60fps. Everything - update, draw, all of it - has to fit inside that budget, or the framerate collapses into a slideshow and the magic leaks out
- Measure first, guess never. Bolt on a live FPS meter, wrap suspicious code in a
performance.now()timer, and learn the browser's Performance panel (the flame chart). The slow part is almost never the part you'd guess - your eyes lie, the clock doesn't - The number one killer is allocating inside the loop. Every
{ }ornewin per-frame code makes garbage, and the garbage collector's cleanup freeze is that periodic hitch you feel. Pull constants out, mutate in place, and the stutter vanishes - Object pooling kills the births-and-deaths version of that garbage: allocate all your particles once, mark them dead/alive, and reuse the slots forever. After startup the system allocates nothing
- The other killer is O(n squared) work - every particle checking every other one (flocking, episode 18 and 50). Double the count, quadruple the work
- Spatial hashing fixes it: chop space into a grid, bucket everyone into cells, and only check the 3x3 block of cells around each one. Work grows roughly linearly instead of squared. (And compare squared distances to skip the square root - trig episode trick)
- On the draw side, batch by state: group by colour, set
fillStyleonce per group, one path per batch. It's the canvas cousin of episode 70's instancing - one big job beats a thousand tiny ones - Cache what doesn't change: pre-render static backgrounds once to an offscreen canvas and blit them in a single
drawImageeach frame instead of redrawing them forever - For genuinly enormous computation, Web Workers + OffscreenCanvas move the heavy work off the main thread so the page stays responsive - but only reach for it after you've measured that the main thread is really your wall
So that's the answer to the little question I ran away from last time. The sketch running at eight frames a second wasn't cursed, and the one that flew wasn't blessed - one of them was making garbage every frame, or checking a million pointless distances, or redrawing a static background sixty times a second, and the fast one wasn't. Performance isn't magic and it isn't about being clever. It's about looking - putting a clock on your code, believing the clock over your gut, and cutting the one wide bar the profiler points at. Do that, and speed stops being luck and starts being something you can just reach for whenever a sketch needs it. Now go make a slow one fly :-).
Sallukes! Thanks for reading.
X
Leave Learn Creative Coding (#132) - Performance Optimization Deep Dive to:
Read more #stem posts
Best Posts From femdev
We have not curated any of femdev's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.
More Posts From femdev
- Learn Creative Coding (#132) - Performance Optimization Deep Dive
- Learn Creative Coding (#131) - Building Your Own Creative Framework
- Learn Creative Coding (#130) - Sound Art: Beyond Music
- Learn Creative Coding (#129) - Live Coding Music
- Learn Creative Coding (#128) - Mini-Project: Audiovisual Composition
- Learn Creative Coding (#127) - Advanced Sound-Reactive Visuals
- Learn Creative Coding (#126) - MIDI and OSC: Connecting Systems
- Learn Creative Coding (#125) - Sampling and Granular Synthesis
- Learn Creative Coding (#124) - Spatial Audio: Sound in Space
- Learn Creative Coding (#123) - Audiovisual Synchronization