More than half the simulations on this site use plain Canvas 2D.
Cellular automata, agent models, signal processing charts, and traffic
simulations all run in a <canvas> without a scrap
of WebGL. Most hit a steady 60 FPS on a 2019 mid-range laptop — but
that didn't happen by accident. Here's what makes the difference.
The Bottlenecks to Know
Canvas 2D performance problems fall into three buckets: too many draw calls, too much pixel work per frame, and too much garbage collection. Fix those three and most simulations are smooth.
Use typed arrays for particle state
The single biggest win for particle systems is replacing an array of
objects with parallel typed arrays. Object properties live in heap
memory; accessing them on each frame forces pointer chasing and
triggers GC pressure.
Float32Array is contiguous in memory and CPU-cacheable.
// ❌ Slow — object heap, GC pressure
const particles = Array.from({ length: 5000 }, () => ({ x: 0, y: 0, vx: 0, vy: 0 }));
// ✅ Fast — contiguous Float32Array, zero GC
const N = 5000;
const px = new Float32Array(N); // x positions
const py = new Float32Array(N); // y positions
const vx = new Float32Array(N); // x velocities
const vy = new Float32Array(N); // y velocities
Batch draw calls with a single path
Every beginPath() / fill() pair is a state
flush to the GPU compositor. For N identical circles, use a single
compound path:
ctx.beginPath();
for (let i = 0; i < N; i++) {
ctx.moveTo(px[i] + r, py[i]);
ctx.arc(px[i], py[i], r, 0, Math.PI * 2);
}
ctx.fillStyle = '#22d3ee';
ctx.fill(); // one GPU call for all N circles
Use ImageData for grid simulations
Cellular automata and reaction-diffusion patterns update every cell
each frame. Calling fillRect() per cell is catastrophic —
thousands of draw calls per frame. Instead, write directly to a
Uint8ClampedArray and push it in one
putImageData():
const W = 320, H = 240;
const img = ctx.createImageData(W, H);
const px = img.data; // Uint8ClampedArray, RGBA
function render(grid) {
for (let i = 0; i < W * H; i++) {
const v = grid[i] * 255 | 0;
px[i * 4] = v; // R
px[i * 4 + 1] = v; // G
px[i * 4 + 2] = v; // B
px[i * 4 + 3] = 255; // A (opaque)
}
ctx.putImageData(img, 0, 0); // one GPU upload
}
Tip: Work at a reduced resolution (e.g. 160×160)
and scale up with ctx.scale() or CSS
image-rendering: pixelated. A 4× scale looks sharp and
reduces pixel writes by 16×.
Fixed timestep with delta-time accumulator
The physics update must be independent of render framerate. Use a fixed timestep accumulator so simulations are deterministic and don't explode at high framerates:
const DT = 1 / 60; // fixed physics step (seconds)
let acc = 0;
let prev = performance.now();
function loop(now) {
acc += Math.min((now - prev) / 1000, 0.1); // cap at 100 ms to survive tab switch
prev = now;
while (acc >= DT) {
updatePhysics(DT);
acc -= DT;
}
render();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
Offscreen canvas for static layers
If your simulation has a static background (a grid, axes, or a base
image), draw it once to an offscreen canvas and
drawImage() it to the main canvas each frame. This is far
cheaper than re-drawing the background.
// Setup (once)
const bg = document.createElement('canvas');
bg.width = canvas.width;
bg.height = canvas.height;
const bgCtx = bg.getContext('2d');
drawGrid(bgCtx); // expensive — runs once
// Render loop (every frame)
ctx.drawImage(bg, 0, 0); // fast blit
drawParticles(ctx); // only particles change
Avoid mid-loop allocations
Any new Array(), {}, or destructuring inside
the render loop allocates heap memory that will eventually be
garbage-collected. The GC pause causes visible stutters. Pre-allocate
everything outside the loop:
// ❌ Allocates on every frame
function update() {
const forces = particles.map(p => ({ fx: 0, fy: gravity }));
}
// ✅ Reuse pre-allocated arrays
const fx = new Float32Array(N);
const fy = new Float32Array(N);
function update() {
fy.fill(gravity); // reset, no allocation
}
Spatial hashing for N-body or collision
Brute-force N² neighbour checks kill performance above ~500 particles. A simple spatial hash cuts this to O(N) average:
const CELL = 20; // grid cell size in pixels
const grid = new Map();
function hashCell(x, y) { return `${x / CELL | 0},${y / CELL | 0}`; }
// Insert
grid.clear();
for (let i = 0; i < N; i++) {
const key = hashCell(px[i], py[i]);
if (!grid.has(key)) grid.set(key, []);
grid.get(key).push(i);
}
// Query neighbours of particle i (only 9 cells to check)
function neighbours(i) {
const cx = px[i] / CELL | 0, cy = py[i] / CELL | 0;
const result = [];
for (let dx = -1; dx <= 1; dx++)
for (let dy = -1; dy <= 1; dy++)
(grid.get(`${cx+dx},${cy+dy}`) || []).forEach(j => result.push(j));
return result;
}
Profile first, optimise second
Chrome DevTools → Performance tab → record 5 seconds of the simulation. Look for long yellow JS tasks in the flame chart. The tall bars tell you exactly which function to optimise. Don't guess — the profiler shows the truth.
A common mistake: optimising the physics loop when the bottleneck is actually the draw calls (or vice versa). Profile before spending an hour refactoring the wrong thing.
Putting It Together
The Reaction-Diffusion simulation on this site runs a 160×160
Gray-Scott grid at 60 FPS using techniques 3, 4, and 6 above. The
Boids 2D simulation handles 1 000 agents using techniques 1, 2, 4, and
7. The traffic (NaSch) model updates a 200-cell ring road using
technique 3 and displays a 80-row space-time diagram via a single
putImageData per frame.
None of these required Three.js or WebGL. The optimisations above made the difference — not the renderer.