Canvas 2D vs WebGL for Data Visualization
Pick the wrong rasterizer and you either ship a heatmap that drops to 8fps or burn two weeks writing shaders for a chart 200 rectangles could have drawn.
Concept overview
Canvas 2D and WebGL are both immediate-mode pixel surfaces, but they sit on opposite sides of the CPU/GPU divide. Canvas 2D issues drawing commands that the browser’s 2D engine rasterizes — mostly on the CPU, one command at a time. WebGL hands vertex and fragment programs to the GPU, which rasterizes thousands of primitives in parallel. The decision between them is fundamentally about throughput per frame versus engineering cost, and it belongs in the larger context of the Core Rendering Engines & Tradeoffs overview, alongside the retained-mode SVG option.
The contract is different too. Canvas 2D gives you a rich, stateful drawing API (fillRect, arc, fillText, gradients, clipping) with no setup. WebGL gives you a low-level pipeline: you compile a vertex shader and a fragment shader, upload geometry into typed-array buffers, bind attributes, and issue draw calls. There is no fillText; text must be a texture atlas. Before committing to WebGL, read the WebGL fundamentals for visualizations guide, and if you are still weighing retained mode, compare against the SVG vs Canvas architecture tradeoffs.
The single most important mental model is where the per-frame cost lives. In Canvas 2D, every primitive is a function call that crosses the boundary from JavaScript into the browser’s 2D rasterizer. Drawing 10,000 circles is 10,000 round trips through that boundary, plus 10,000 path setups, fills, and state lookups. The cost scales linearly with the number of marks and it is paid by the CPU on the main thread, frame after frame. In WebGL, you pay a large one-time cost to compile shaders and upload geometry, and then each frame’s cost is dominated by the number of draw calls you issue, not the number of primitives those calls produce. A single drawArrays call can ask the GPU to rasterize a million points, and the GPU does that work in massively parallel hardware that the main thread never touches. This is why the comparison is not “Canvas is slow, WebGL is fast” but rather “Canvas cost grows with mark count, WebGL cost grows with draw-call count” — and why the entire craft of WebGL performance is collapsing many logical draws into one.
The second axis of the decision is developer cost, and it is easy to underweight when you are staring at a benchmark. Canvas 2D is a productive, forgiving API: you can build a working chart in an afternoon, text and gradients come for free, and debugging is ordinary JavaScript. WebGL is an unforgiving state machine. You manage shader compilation and linking, attribute and uniform locations, buffer binding order, blending modes, framebuffers for hit-testing, a glyph atlas for text, and a recovery path for context loss. A WebGL scatter plot that a senior engineer could prototype in Canvas in a day can take a week to get right, and it ships with a persistent backlog of driver- and GPU-specific edge cases. That cost is worth paying when the data volume genuinely demands it and entirely wasteful when it does not — which is why the honest default is to start in Canvas 2D and escalate only on measured evidence.
Decision table
| Factor | Canvas 2D | WebGL |
|---|---|---|
| Sweet-spot element count | up to ~5k–10k per frame | 50k–10M+ points |
| Draw-call model | one command per shape (CPU-bound) | batched/instanced (GPU-parallel) |
| Text & rich styling | native (fillText, gradients) |
manual texture atlas |
| Hit-testing | re-draw to hidden buffer or spatial index | color-pick pass or CPU index |
| Animation headroom | tight past a few thousand shapes | large, GPU does the work |
| Dev cost | low | high (shaders, buffers, GL state) |
| Browser/driver risk | minimal | context loss, GPU blocklists |
| Best for | bar/line/area, <10k marks, rich labels | dense scatter, maps, particle fields |
Choosing by element count
A practical rule of thumb on mainstream hardware, assuming a 16.67ms frame budget:
- Under ~2,000 dynamic marks: SVG or Canvas 2D; pick SVG if you need DOM-level interactivity and accessibility.
- 2,000–10,000 marks: Canvas 2D with dirty-rectangle redraws and batched paths.
- 10,000–100,000 marks: WebGL with instanced or point-sprite rendering; Canvas 2D will start missing frames.
- 100,000+ marks: WebGL is the only realistic option; consider typed-array geometry and off-main-thread setup, covered in rendering 100k scatter points without frame drops.
These thresholds are not constants; they move with the work each mark requires. A flat filled rectangle is cheap; a stroked, shadowed, anti-aliased path with a radial gradient fill is many times more expensive per mark, which can pull the Canvas ceiling down to a couple of thousand shapes. Animation also changes the math: a static chart of 20,000 Canvas shapes drawn once is fine, but the same 20,000 shapes redrawn every frame during a transition will blow the budget. The honest way to use these numbers is as a starting hypothesis you then validate with a profiler against your real marks, at your real data volume, during your real worst-case interaction — a zoom that fits the whole dataset, a brush that updates a tooltip on every pointer move, or a stream that appends points at 30Hz.
The hybrid escape hatch
You are not limited to one technology per page. A common production pattern layers a static or low-frequency Canvas/SVG layer (axes, gridlines, labels, legend) over or under a WebGL layer that handles only the dense data marks. The WebGL layer redraws on pan and zoom while the axis layer redraws only when the scale domain changes, and text stays in the layer that has a real text API. This sidesteps WebGL’s two worst ergonomics — text and rich styling — while keeping its throughput where it matters. Similarly, an SVG overlay can carry interactive annotations, brushes, and crosshairs as real DOM elements with native events and accessibility, sitting on top of a Canvas or WebGL data plane. Choosing the rasterizer is rarely all-or-nothing.
Reference spec
// Canvas 2D: trivial setup, per-shape cost.
const ctx: CanvasRenderingContext2D = canvas.getContext("2d")!;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill(); // one fill per point → O(n) CPU commands per frame
// WebGL: setup-heavy, then near-constant per-frame draw cost.
const gl: WebGL2RenderingContext = canvas.getContext("webgl2")!;
const program: WebGLProgram = compileProgram(gl, vertSrc, fragSrc);
const buffer: WebGLBuffer = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, positions /* Float32Array */, gl.STATIC_DRAW);
gl.drawArrays(gl.POINTS, 0, pointCount); // one call draws ALL points
Hit-testing diverges sharply. Canvas 2D either re-renders shapes to an off-screen buffer with unique colors and reads back a pixel, or maintains a CPU-side spatial index (quadtree). WebGL typically uses a dedicated color-pick render pass: each point gets a unique color encoding its index, you render to a framebuffer, then readPixels at the cursor decodes the index.
The reason hit-testing deserves up-front planning is that both surfaces are opaque to the pointer. Unlike SVG, where each <circle> is a real DOM node that dispatches its own mouseover, a Canvas or WebGL surface is a single element — the browser only knows the pointer is somewhere over the bitmap. You must answer “which mark is under the cursor?” yourself. For static or slowly-changing data, a d3.quadtree built once from the mark coordinates answers that in O(log n) per pointer event and costs nothing per frame; this is usually the right choice and works identically for Canvas and WebGL. The color-pick framebuffer technique earns its complexity only when marks move every frame (so a spatial index would need constant rebuilding) or when pixel-perfect hit shapes matter more than nearest-point. Deciding this early matters because retrofitting hit-testing onto a WebGL renderer often means adding a second shader program and a second render target — work you want to scope before you start, not discover halfway through.
Step-by-step implementation
Performance & memory notes
Canvas 2D’s per-frame cost is O(n) in CPU-issued commands; each arc/fill crosses the JS-to-rasterizer boundary, and that overhead — not pixel fill — is what caps it around 5k–10k animated shapes. WebGL’s per-frame cost is dominated by draw calls, not primitives: one drawArrays for 100k points is far cheaper than 100k Canvas commands, because the GPU rasterizes them in parallel. This is why instancing and batching matter so much — collapsing many draws into one is the single biggest WebGL win.
On memory: Canvas 2D holds a single backing-store bitmap plus whatever JS objects you keep for redraws. WebGL holds typed-array geometry in GPU buffers; a 100k-point scatter at two floats per point is only ~800KB of Float32Array, but you must deleteBuffer/deleteProgram/deleteTexture on teardown or the GPU memory leaks independently of the JS heap. WebGL also risks context loss (driver reset, tab backgrounding); register a webglcontextlost handler and rebuild resources on webglcontextrestored.
A few patterns keep both surfaces inside budget. For Canvas 2D, the biggest wins come from not redrawing what did not change: split static content (axes, gridlines) onto a separate canvas that you paint once, and reserve the dynamic canvas for the marks that actually move. Use dirty-rectangle clearing — clearRect over just the changed region instead of the whole bitmap — when only a small area updates. Batch primitives that share style into a single path so you pay one fill for many shapes rather than one fill per shape. And cache repeated glyphs or symbols into a pre-rendered offscreen canvas, then drawImage them, which is far cheaper than re-rasterizing the same marker thousands of times. For WebGL, the equivalent disciplines are uploading geometry once with STATIC_DRAW and animating through uniforms rather than re-uploading buffers, collapsing draws with instancing, and minimizing GL state changes (program switches, texture binds) because each one is a pipeline stall. Both surfaces reward moving expensive setup off the main thread: OffscreenCanvas lets you build and even render in a worker, keeping the UI thread free during heavy loads.
It is also worth being clear-eyed about the operational risks WebGL adds. GPU blocklists mean a fraction of your users — old drivers, certain virtualized environments, some corporate machines — will silently fail to get a WebGL context at all, so a robust app feature-detects and falls back to Canvas. Context loss is not an edge case but a routine event: backgrounding a tab on mobile, the OS reclaiming GPU memory, or a driver crash all drop the context, and any app that does not handle webglcontextlost/webglcontextrestored shows a blank chart afterward. Canvas 2D carries none of this baggage — it is available essentially everywhere and never loses its buffer — which is part of why the pragmatic answer for moderate data volumes is so often “stay on Canvas.” The throughput WebGL buys is real, but it comes bundled with a maintenance surface you are signing up to own.
Accessibility checklist
Troubleshooting
Canvas 2D scatter janks at ~8k points. You have hit the per-command CPU ceiling. Batch into fewer paths, cache static layers, or move to WebGL point sprites.
WebGL renders nothing (black canvas). Usually a shader compile/link failure or a clip-space coordinate bug. Check gl.getShaderInfoLog/gl.getProgramInfoLog and confirm positions are mapped into the −1…1 range.
WebGL chart goes blank after the tab is backgrounded. The GL context was lost. Add webglcontextlost/webglcontextrestored listeners and rebuild buffers, textures, and programs on restore.
Hit-testing is slow or wrong. Re-rendering the whole scene per pointer move is O(n) per event. Use a quadtree (Canvas) or a color-pick framebuffer pass with readPixels (WebGL).
Text looks fine in Canvas but is missing in WebGL. WebGL has no native text. Render glyphs to a texture atlas and draw textured quads, or overlay an HTML/SVG label layer.
Frequently Asked Questions
When is Canvas 2D fast enough versus needing WebGL?
Canvas 2D comfortably handles up to roughly 5,000–10,000 animated shapes per frame on mainstream hardware. Past that, the per-command CPU overhead pushes you over the 16.67ms budget and you should move to WebGL, which batches geometry onto the GPU and scales to hundreds of thousands of points.
Why is WebGL so much faster for large point counts?
WebGL’s per-frame cost is driven by the number of draw calls, not the number of primitives. A single drawArrays can rasterize 100,000 points in parallel on the GPU, whereas Canvas 2D issues one CPU-bound command per shape. Collapsing many primitives into one batched or instanced draw is what unlocks the throughput.
How do I hit-test points in WebGL?
The standard technique is a color-pick pass: render each point to an off-screen framebuffer using a unique color that encodes its index, then call readPixels at the cursor position and decode the index. For static data a CPU-side quadtree built from the same coordinates is simpler and avoids a second render pass.
Is the extra WebGL complexity ever not worth it?
Often. If your chart has rich text, gradients, fewer than a few thousand marks, or needs to ship this week, Canvas 2D (or SVG) is the pragmatic choice. WebGL’s shader, buffer-management, context-loss, and text-atlas burden only pays off when the element count genuinely exceeds what the CPU rasterizer can sustain.
Can I mix Canvas 2D and WebGL in the same visualization?
Yes, and it is a common production pattern. Layer a WebGL canvas for the dense data marks beneath or above a Canvas 2D (or SVG) layer that handles axes, labels, legends, and interactive annotations. The WebGL layer gives you throughput where the mark count is high; the 2D layer gives you a real text API, easy styling, and native events for the chrome around the data. The two layers redraw on independent triggers, so the cheap chrome does not re-render every time the data plane pans.
How do I measure whether Canvas 2D is actually over budget?
Record a Performance trace in DevTools at your worst-case data volume while performing the heaviest interaction, then look at the scripting and rendering time per frame. If frames consistently exceed about 16.67ms or you see long tasks during interaction, Canvas 2D is over budget for that workload. Profiling the real interaction is essential — a chart can be perfectly smooth when static and fall apart only during a continuous zoom, which a one-shot render measurement would never reveal.
Related
- Core Rendering Engines & Tradeoffs — the parent overview of rendering pipelines.
- WebGL fundamentals for visualizations — shaders, buffers, and the GL pipeline.
- SVG vs Canvas architecture — the retained-mode alternative.
- Rendering 100k scatter points without frame drops — the high-volume implementation.
- Memory management in heavy charts — releasing GPU and DOM resources.