byprofile_photo

High-Performance Canvas Rendering with CanvasKit

You pick CanvasKit because it's fast. WebGL-backed, WASM-powered β€” the whole pitch. You build your visualization, throw in a few hundred shapes, add some grid lines, slap on a scroll handler.

And then it stutters.

Not because CanvasKit is slow. Because you're treating it like a regular Canvas 2D API. That mindset will cost you. Let's fix it.

πŸ—‚οΈ What Is CanvasKit

CanvasKit is a WebAssembly port of Skia β€” the same 2D graphics engine that powers Chrome, Android, and Flutter. It gives you a GPU-accelerated canvas with a rich drawing API, all from JavaScript.

The catch? It runs on a WASM heap, not the JS heap. That changes how you think about memory, allocation, and rendering lifecycle.

The Problem

Let's say you have 500 data elements. Your viewport shows 20 of them. You're drawing all 500. Every frame. Recomputing coordinates, colors, and text for things completely off-screen.

The GPU can handle those draw calls. But your CPU is burning every frame on useless work.

This is the most common mistake in canvas-based rendering. And it compounds β€” the more elements you add, the worse it gets, even if the viewport never changes.

πŸ—ƒοΈ Layering β€” Draw Less, Not Faster

Before we optimize individual draw calls, let's talk architecture. Because the biggest win isn't in how you draw β€” it's in what you decide to redraw.

A canvas visualization is not one thing. It's a stack of independent layers, each with a completely different reason to update:

PictureRecorder β€” not makeImageSnapshot

Most tutorials suggest offscreen surfaces with makeImageSnapshot(). The idea is right but the tool is wrong.

makeImageSnapshot() copies pixel data GPU β†’ CPU β†’ GPU every time you use it. You've added a round-trip to a system that was supposed to eliminate redraw work.

The correct primitive is PictureRecorder. Instead of capturing pixels, it records a display list β€” a serialised sequence of draw commands. The result, SkPicture, lives entirely on the GPU side. Replaying it costs almost nothing.

The Scroll Quantum Trick

But wait β€” even with PictureRecorder, rebuilding on every pixel of scroll is wasteful.

The trick is to record each picture with a buffer larger than the viewport β€” a thousand or so pixels in each scrollable direction. Then you snap the camera position to a fixed step size (the quantum).

The result? Scrolling through thousands of elements triggers a content layer rebuild once every few hundred pixels. Every frame between those rebuilds costs essentially nothing.

Per-Layer Dirty Masks

One boolean dirty flag isn't enough. A selection change doesn't affect the grid. A data update doesn't affect the cursor.

const DirtyFlags = {
  SCROLL:    1 << 0,
  ZOOM:      1 << 1,
  DATA:      1 << 2,
  SELECTION: 1 << 3,
  CURSOR:    1 << 4,
};

Each layer wakes up only for what can actually affect it. Nothing more.

DPR Pixel Snapping

When you replay a layer with a sub-quantum translation, geometry can straddle pixel boundaries, causing blurriness.

const dpr = window.devicePixelRatio;
const dx = Math.round((anchorX - camera.x) * dpr) / dpr;
const dy = Math.round((anchorY - camera.y) * dpr) / dpr;
canvas.translate(dx, dy);

Frozen Regions

Many visualizations have regions that stay fixed while content scrolls β€” a pinned header or a frozen axis. Don't include these in the regular rebuild cycle.

Treat them as a separate SkPicture with the relevant camera axis frozen at zero.

πŸ”’ Virtualize

500 elements in your dataset. 20 visible. Draw only the 20. Render time becomes proportional to viewport size, not dataset size. 5000 elements performs the same as 50.

const itemHeight = 40;
const firstVisible = Math.floor(scrollY / itemHeight);
const lastVisible = Math.ceil((scrollY + viewportHeight) / itemHeight);

for (let i = firstVisible; i <= lastVisible; i++) {
  if (!items[i]) continue;
  drawItem(canvas, items[i]);
}

🚫 Disable Antialiasing on Rectangles

Skia does analytical antialiasing. Beautiful, but runs per-shape. For an axis-aligned rectangle on whole pixel boundaries? It's pure wasted work.

⚠️ One caveat

This only works when your coordinates land on whole pixel boundaries. You need to Math.round() before drawing β€” otherwise disabling AA makes things look worse.

🏊 Pool Your Paint and Path Objects

Every new CanvasKit.Paint() and new CanvasKit.Path() is a WASM heap allocation. Doing this inside your draw loop will cause frame drops.

The robust solution is a pool. The key call when pulling a path from the pool is path.rewind() β€” it clears the geometry without deallocating the internal buffer.

🎯 Batch Geometry

Every individual drawRect() is a separate GPU command. The fix: collect all geometry of the same visual style into one CanvasKit.Path and issue a single drawPath().

// Batching N calls into 1
const path = pathPool.acquire();
for (const item of visibleItems) {
  path.addRect(itemRect(item));
}
canvas.drawPath(path, paint);

πŸ”€ Text Caching

Text is the silent bottleneck. Render each unique label exactly once into a small offscreen surface. Snapshot it as an image. On every subsequent frame, blit the image.

Critical: The JavaScript GC does not touch WASM memory. You MUST call .delete() on the image when you evict it from your cache.

🏁 Final Thoughts

These optimizations compound. Virtualization means fewer elements to process. Batching collapses those elements into a handful of draw calls. Pooling removes allocation pressure.

β†’ Found this helpful? Let me know or share it with a fellow dev πŸš€