60 FPS Physics with Canvas 2D

Canvas 2D is fast enough for most physics simulations — if you know which bottlenecks to avoid. Here are eight concrete techniques that keep this project's simulations butter-smooth even on mid-range hardware.

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.

Typical ceiling
5 000
fillCircle() calls at 60 FPS (mobile)
With ImageData
160×160
pixel grid at 60 FPS, CPU only
1

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
2

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
3

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×.

4

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);
5

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
6

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

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;
}
8

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.