Codex vs Claude

Canvas or Static Asset: Deciding Where the Picture Gets Computed

David Guzenburg/ / 10 min read

Both render the same pixels. One is computed once by you, the other on every load on someone else's phone.

toolingperformancedesignbuild systems

The question is where the picture gets computed, not how it looks

Ask an agent for a visualisation and it will offer two shapes of answer: a static asset it produces now, or a canvas script that draws the thing in the browser. Both render. They are not alternatives in any visual sense — you can usually get the same pixels either way — and treating the choice as aesthetic is how the decision gets made by whichever example the agent read last.

The real question is when the drawing happens and who pays for it. A static asset is computed once, by you, and every viewer downloads the result. A canvas is computed on every load, on the viewer's device, on the main thread, after your JavaScript has arrived and run. That is a large difference in cost distribution, and it is invisible in the output.

Three places a picture can be computed

At build time. The cheapest for the viewer and the most constrained: the picture must be the same for everyone. Icons, diagrams, charts of published data, social preview cards. Costs nothing at runtime, caches perfectly, works with JavaScript disabled, and is done before anyone asks.

On the server, per request. Necessary when the picture depends on data the build did not have — a chart of this account's usage, a personalised summary card. Costs server time and complicates caching, but the viewer still receives a finished image.

In the browser, per load. Necessary when the picture depends on interaction: zooming, panning, brushing, animating, responding to a cursor. This is the only case that genuinely requires canvas, and it is the case agents reach for by default regardless.

Canvas is opaque to everything else

A canvas is a bitmap with a script attached. Nothing outside that script can see into it. There is no DOM inside it, so there are no elements to inspect, style, select, translate or announce. A screen reader encountering a canvas finds an image with whatever label you remembered to attach. A search crawler finds nothing. A user selecting text finds no text. Your CSS cannot restyle it for dark mode; the script has to know about the theme and redraw itself.

Each of those is fine when you have chosen it. Each is a defect when it arrived because the agent generated a canvas for a bar chart that could have been eight <rect> elements.

The one-question test

Does the picture need to respond to a pointer or animate continuously? If not, it does not need canvas. Almost every chart in almost every dashboard fails this test, and almost every chart in a dashboard is drawn on a canvas.

The blurry canvas, which agents get wrong every time

Here is the specific defect worth knowing about, because it appears in generated canvas code with striking consistency and because the symptom is easy to misattribute.

A canvas has two sizes: the CSS size it occupies on the page, and the backing store it actually draws into. On a display with a device pixel ratio above one — which is every phone and most laptops — setting only the CSS size means the browser scales a smaller bitmap up, and everything you drew is soft. The chart does not look broken. It looks slightly cheap, and people report it as "the fonts look wrong."

function fitCanvas(canvas, ctx) {
  const dpr = window.devicePixelRatio || 1;
  const rect = canvas.getBoundingClientRect();

  canvas.width  = Math.round(rect.width  * dpr);   // backing store
  canvas.height = Math.round(rect.height * dpr);
  canvas.style.width  = rect.width + "px";         // layout size
  canvas.style.height = rect.height + "px";

  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);          // draw in CSS pixels
}

Without setTransform at the end you have a correctly sized buffer and every coordinate in your drawing code is now half-scale, so people add it and then double all their numbers instead. Both halves are needed. And this has to run again on resize and when the window moves between displays with different ratios, which is the part almost nobody handles.

Canvas has no fallback unless you write one

An <img> that fails to load shows alt text. A canvas whose script fails shows a blank rectangle of exactly the right size, which is worse, because the layout is intact and nothing signals that anything is missing. Errors inside a drawing routine are especially good at this: the first half of the chart renders and the rest silently does not.

If you ship canvas, ship a fallback: content inside the <canvas> element for the no-JavaScript case, a try around the draw call that swaps in a table on failure, and — for anything users depend on — the underlying numbers available as text somewhere on the page. The data table is the accessible version, the error state and the thing someone will copy into a spreadsheet, all at once.

The hybrid is usually the right answer

The framing as a binary is itself the mistake. Axes, gridlines, labels, legends and annotations are text and structure: they belong in the DOM, where they are selectable, translatable, styleable and announced. The dense layer — forty thousand scatter points, a heatmap, a live waveform — belongs on a canvas positioned behind them.

You get accessible, themeable chrome and a fast dense layer. It is more work than either pure approach and it is what the serious charting libraries do internally, for these reasons.

When canvas is straightforwardly correct

Continuous animation. Datasets where the element count would make the DOM struggle — the threshold is lower than people expect, somewhere in the low thousands of nodes before layout and style recalculation start to show. Pixel-level manipulation: image filters, generative visuals, anything reading back what it drew. Games. Anything where the frame is genuinely a frame rather than a diagram.

In those cases the opacity is not a cost you are paying by accident, and the runtime computation is the point.

Measure the thing you traded for

The reason to be deliberate is that the cost is real and lands on the viewer's device, where you will not see it. A canvas chart that takes 180 milliseconds to draw is imperceptible on the machine you built it on and noticeable on a four-year-old phone, and it competes with everything else your page wants the main thread for.

If you have gone the canvas route, measure the draw call, measure it on a throttled CPU, and consider an offscreen canvas in a worker for anything substantial. If the numbers surprise you, the static asset was probably the right answer, and the honest reason it was not chosen is that the agent offered a canvas and nobody framed it as a decision.

Server rendering and the blank first paint

A canvas cannot render on the server. Whatever framework you are using, however good its streaming and hydration story, the canvas is empty in the HTML that arrives and stays empty until the JavaScript has downloaded, parsed, executed and drawn.

On a fast connection that gap is a flicker. On a slow one it is a large blank rectangle where the main content of the page should be, held open by the layout so that nothing else fills it. If the picture is the point of the page — a dashboard, a report, a chart someone was linked to — then the page has no content until the last step of the pipeline completes.

An SVG rendered on the server arrives in the HTML, visible on first paint, before any script runs. For content-shaped visualisations that difference is the whole argument, and it does not show up in any local measurement because locally the script arrives instantly.

Text inside a canvas is the accessibility cliff

Worth stating separately, because it is where the compromise is worst. Axis labels, data values, legend entries and annotations drawn with fillText are pixels. They cannot be read by a screen reader, selected, copied, found with in-page search, translated by the browser, or scaled by a user's font-size preference.

The last one catches people. A viewer who has increased their default font size gets larger text everywhere on your page except inside the canvas, which stays at whatever size the script hardcoded. The chart becomes the least legible element on the page for precisely the person who needed it larger.

An aria-label on a canvas is not a description

Labelling a canvas "sales chart" tells a screen-reader user that a chart exists and nothing about what it shows. If the data matters, the accessible version is a table — visually hidden if you must, but present and correct. Anything less is a label standing in for content.

Printing, screenshots and everything downstream

Canvas content survives a screenshot and often nothing else. It prints at screen resolution rather than print resolution, so a chart that is crisp on a display comes out of a printer visibly pixelated. It does not scale in a PDF export. It cannot be pasted into a document as anything but a bitmap.

SVG prints at the printer's resolution, scales losslessly in a PDF, and pastes into most document tools as an editable object. For anything users are likely to take out of the browser — reports, invoices, anything with an export button — that difference is more important than rendering performance, and it is invisible until someone in finance sends you a photograph of a printout.

What to ask for instead

The prompt that produces a better answer states the constraint rather than the technology: "render this as inline SVG in the DOM so it is accessible and prints cleanly; use canvas only if the element count would exceed a few thousand, and say so if you think it does."

That gives the agent the decision criteria rather than the decision, which is generally the right division of labour. It also produces a useful answer in the case where canvas genuinely is required, because the agent has to justify the choice rather than defaulting to it, and the justification is a number you can check.

The default that serves most teams

Inline SVG in the DOM, rendered on the server, for anything that is a diagram, a chart, a badge or an icon. Canvas for animation, interaction and data dense enough that you can name the number. A hybrid when the dense layer sits under structural chrome.

Reversing that default — canvas first, SVG when someone complains — is what happens when nobody states a policy, because canvas is what the examples use. The cost is not paid by whoever chose it. It is paid by the viewer on a slow device, the person using a screen reader, and whoever eventually needs the chart in a PDF, and none of those three are in the review.

There is one more asymmetry worth stating plainly. Choosing SVG and being wrong costs you performance, which you will notice, measure and can fix by moving one layer to a canvas. Choosing canvas and being wrong costs you accessibility, search visibility, print fidelity and server rendering — none of which you will notice, because nothing reports them and the page looks correct on your machine. When the failure modes are that asymmetric, the default should sit on the side that fails loudly, and that is the DOM.

One last practical note on getting the hybrid right. Keep the two layers in separate stacking contexts and size them from the same source of truth — a shared scale function, not two copies of the arithmetic. The failure mode of a hand-built hybrid is a canvas layer and a DOM layer that disagree by a pixel or two after a resize, which reads as a rendering bug and is really two implementations of the same coordinate mapping drifting apart.

Takeaway

Static asset versus canvas is a question about where computation happens, not how the picture looks. Canvas is opaque to accessibility, search, selection and CSS, has no fallback unless you write one, and is drawn blurry by default on every HiDPI display until you size the backing store by devicePixelRatio and set the matching transform. Reserve it for animation, interaction and genuinely dense data; put axes and labels in the DOM; and for everything else compute the picture once at build time.

Keep reading
Codex vs Claude

Why Claude Code Draws in SVG: Missing Tool, Not Missing Eyes

The reason an agent writes vector rather than raster is a tool list, not a rendering engine. What follows: source that diffs, deterministic rasterisation in the build, and where vector genuinely loses.

Codex vs Claude

Optimising SVG: The Byte Count Reports the Saving, Not the Damage

Default optimiser settings strip viewBox, mangle referenced ids, merge animated paths and round flush edges apart. A safe checked-in configuration, and verifying by rendered pixels rather than file size.

Codex vs Claude

Codex Generates Raster Assets: The Repository Problem That Follows

Codex writes real PNGs through its image_gen tool and $imagegen skill, into a cache directory rather than your tree. What that means for review, reproducibility, cost and the placeholders that ship by accident.

Codex vs Claude

Mockup to Code: The Component Matches the Picture, Which Is the Problem

An image carries layout and proportion and omits breakpoints, states, content variance and semantics. Why generated UI is full of bracketed pixel values, and the token-extraction step that fixes it.

← Host Administration by Agent: Sorting Changes by How Badly They Undo  ·  Screenshot-Driven UI Debugging: The Picture Is Evidence, Not a Diagnosis →

All codex vs claude articles  ·  Every article