A Chrome extension that records what a web page draws on a canvas and lets you scrub back through it, step by step, in DevTools.
I wanted to scrub through Canvas 2D draw calls the way the Network panel lets you scrub through requests: pick a point in a page's rendering history and see exactly what the canvas looked like at that moment. This is the result. It is version 1.1.0, built and tested by one person, and the limits are listed below rather than hidden.
A canvas is a drawing area on a web page. A program draws on it with code, line by line: "move here, draw a circle, fill it green". Games, charts and drawing apps work this way. Browser developer tools can show you a page's HTML and its network traffic, but they can't show you what was drawn on a canvas or in what order; all you get is the finished picture. CanvasLint records those drawing instructions while the page runs and puts them in a new DevTools panel, so you can drag along a timeline, like scrubbing through a video, and stop at any moment to see the canvas as it was and the instructions that made it.
| Live capture | A scrubbed frame, State inspector open | Findings |
|---|---|---|
![]() |
![]() |
![]() |
All three come from demo/demo.html with the real extension
loaded (npm run shots, e2e/screenshots.e2e.ts). The
generator adds two sloppy lines to one frame of the demo (a repeated
fillStyle and a save() that is never restored) so that Findings has
something real to report.
For you if you write canvas code by hand: small games, creative coding, charts
you draw yourself, or a library that draws on a canvas and you want to see what
it really sends. It works on 2D canvases (getContext('2d'), and an
OffscreenCanvas that the page itself draws on).
Not for you if your canvas is WebGL or WebGPU: those are not intercepted, and
neither is bitmaprenderer. Canvases inside iframes and inside web workers
are not captured either (all_frames: false in the manifest). It is also not
a DOM or accessibility linter, despite the name; "lint" here means checks on
canvas drawing calls.
- Timeline: one tick per frame (what one canvas drew between two screen refreshes). Drag it, or use the arrow keys; jump to the live edge; pause and resume capture.
- Command list: every drawing call in the selected frame. Pick one and the canvas is shown as it was right after that call.
- State inspector with diff: the canvas's settings (colors, line width,
transform,
save()depth, …) after the selected command, with what that command changed shown aswas → now. - Lint findings: seven checks on the frame you are looking at, each with a
link to MDN:
- blurry on high-DPI screens (the canvas's pixel size doesn't match its size on screen times the screen's pixel ratio);
- a setting set to the value it already has, with no drawing in between;
save()andrestore()that don't pair up;getImageDatacalled twice or more in a frame without telling the browser (willReadFrequently);- a shadow switched on while 100+ draw calls run;
drawImageat positions that aren't whole numbers;- a frame of 50+ commands identical to the one before (a guess, marked as a heuristic).
- Export / import: save one canvas's recording to a file, and open it later or on another machine.
- Download
canvas-lint-v1.1.0.zipfrom the Releases page. - Unzip it.
- Open
chrome://extensionsin Chrome (114 or newer). - Enable Developer mode (top right).
- Click Load unpacked and select the unzipped folder.
Then open a page that uses a canvas (for a quick try, demo/demo.html from this repo), open Chrome DevTools on that tab and switch to the CanvasLint panel.
From the open items in docs/LEDGER.md, in plain words (the full list with reasons is under Known limitations):
- If a page draws one canvas onto another (
drawImagewith a canvas as the source, which apps use to cache layers or sprites), the panel doesn't replay that part: it draws nothing for it and says so in the warnings. An app that works this way, such as Excalidraw's scene canvas, replays empty or incomplete. - Each frame is replayed from a blank canvas. What earlier frames left behind (pixels, the current color, a clip) is not carried over.
- Gradients and patterns replay as transparent. Newer canvas methods such as
roundRectshow up as "unknown command" and are not drawn. - The replay canvas has the size the source canvas had at its first drawing; a later resize is not seen.
- Only the frame you are viewing is checked by the lint rules, not the whole history.
- The history cap is per tab (10,000 frames or about 32 MiB), so several drawing tabs each hold their own.
- A page restored with the browser's Back button records nothing more until you reload it (a known bug, pinned by a failing test).
- Import takes one recording at a time, and only inline images replay from a file.
- Video sources and images that failed to load are fetched again on every replay.
- The panel is slower than it was before I added the lint pass and the redesign (numbers in docs/PERF.md).
I checked each of these in the code and the manifest:
- Nothing is sent anywhere by the extension. There is no
fetch,XMLHttpRequest,WebSocketorsendBeaconinsrc/, no remote script or stylesheet in the panel or popup, and no font download. An end-to-end test fails if the panel or popup requests anything from another origin (e2e/privacy.e2e.ts). - Two things do touch the network, both because you asked for them. If a
frame you replay drew an image from a web address, the panel loads that
image again from the same address (only
http(s):anddata:image/addresses; replayEngine.ts). And a "docs" link in Findings opens MDN when you click it. - Recorded drawing stays in memory. History lives in the extension's
service worker memory, per tab, and is gone when the worker or the tab is. A
small marker (tab ids and canvas sizes, no drawing content) is kept in
chrome.storage.session. Nothing is written to disk unless you press Export, and that file contains what the page drew, including anyfillTextcontent. - Permissions:
storage, plus access to all sites, because the capture script has to run on every page atdocument_start. Capture is on by default on every page.
- Frames instead of guessed "blocks". Recording is now per canvas and per animation frame; the old parser that tried to guess where a drawing started and ended is gone. Several canvases per page and several tabs work, and each tab keeps its own history.
- Capture from the first call, before the page's own scripts run.
- Lint findings (seven rules), a State inspector and export/import, all new.
- A redesigned panel: dark and light, keyboard control, accessible labels.
- Replay fixes: a clip or the current path no longer leaks from one frame
into another,
Path2Dreplays correctly, and a badsetLineDashno longer stops the rest of a frame. - Pause, Resume and Reset now reach the page.
- Checks on what a page sends: the worker validates every message from the page and drops what is malformed.
- Faster capture. On my benchmark page (7,034 mostly numeric commands per frame, Chromium 156, idle machine, medians of 3 × 30 s) a frame takes 4.10 ms with the extension and 3.76 ms without it (+9 %); the worker spends 1.69 ms of CPU per page frame, against 11.8 ms in the first version I measured; the command list draws about 35 rows instead of one per command. This is one load; strings, gradients and images were not measured. The table is in docs/PERF.md ("1.1.0 final").
Seven rules run in the panel, on the frame you have selected, and show up in
the Findings tab with an MDN link each. The thresholds are starting points,
not measured values; they are constants at the top of each file in src/lint/.
Each rule has a unit test with a positive and a negative fixture.
| Id | Severity | What it flags |
|---|---|---|
L001 bitmap-dpr-mismatch |
warning | bitmap size differs by more than 2 px from CSS size × devicePixelRatio (likely blurry) |
L002 redundant-state-set |
info | a context property set to the value it already has, with no draw in between |
L003 unbalanced-save-restore |
error | restore() at depth 0, or a frame that ends with an unmatched save() |
L004 readback-without-hint |
warning | getImageData called 2+ times in a frame on a context without willReadFrequently |
L005 shadow-in-hot-path |
warning | shadowBlur > 0 while 100+ draw calls run |
L006 fractional-drawimage |
info | non-integer drawImage destination x/y |
L007 static-redraw |
info, heuristic | a frame of 50+ commands identical to the previous frame's |
The severities are my choice. Rules see one frame at a time, so state set in an earlier frame is unknown to them (details in docs/DECISIONS.md "Lint engine").
The State tab shows the context state after the selected command, read
from the replay context itself rather than parsed from the call log: style,
line, shadow and text properties, line dash, the transform matrix (plus
translate/scale/rotate) and the save() depth. Rows that changed at this
command come first, as was → now. It shows what replay produced, so it is
wrong wherever replay is (see Known limitations).
Export in the panel header writes the selected canvas as gzip'd JSON
(canvaslint-canvas<id>-<timestamp>.json.gz). Import opens such a file as
an "offline" canvas next to the live ones, with the same replay, lint and
inspector; live capture keeps running underneath. The format and the
validation rules are in docs/RECORDING_FORMAT.md.
An e2e test exports, imports and compares the replay canvas's toDataURL()
before and after, byte for byte.
←/→ one command, Shift+←/→ one frame, Home/End oldest frame / live edge, Space pause/resume. The panel follows DevTools' dark or light theme.
Requires Node 20.19+ or 22.12+ (engines in package.json) and Chrome 114+
(minimum_chrome_version in the manifest).
npm ci
npm run buildThen load the dist/ folder as above (Load unpacked).
npm run dev runs the three Vite builds in watch mode (scripts/dev.mjs;
works on Windows cmd.exe and Git Bash). Reload the unpacked extension after a
change.
npm test runs the vitest unit tests, then the Playwright e2e suite in
Playwright's bundled Chromium (one-time npx playwright install chromium).
npm run bench is a manual benchmark, not part of CI.
page (MAIN world, document_start) src/inject
│ wraps every method/setter on CanvasRenderingContext2D and
│ OffscreenCanvasRenderingContext2D; calls through; records the call
│ into compact batches; flushes once per animation frame (numbered),
│ or at 4,096 queued commands, which stays in the same frame
│ window.postMessage({ sentinel, batch })
▼
content script (isolated world) src/content
│ forwards over one long-lived port per tab ('content-relay')
▼
service worker src/background
│ checks every batch from the page (it can be forged) and drops what fails;
│ per tab: frames = one canvas between two animation-frame flushes;
│ oldest whole frames evicted past 10,000 frames or ~32 MiB (estimated);
│ answers LIST_CANVASES / LIST_FRAMES / GET_FRAME / GET_RECORDING
▼
DevTools panel (React + Zustand) src/panel
│ holds one frame at a time: timeline picks a frame, the command list
│ picks a step; lint runs on the loaded frame
▼
replay canvas: new context at the source canvas's bitmap size, run the
frame's commands 0..N; unresolvable refs (e.g. an image that has not loaded)
are skipped with a warning
Arguments that can't cross the message boundary (images, gradients, Path2D,
ImageData) become an opaque reference (SerializedRef); an image's src, or
how a Path2D was built, is kept once per ref and sent with the frame, so
replay can rebuild it. Each canvas's size, DPR and context
attributes are sent once as CANVAS_META, just before its first recorded
command. The list of intercepted methods and setters, and what is not covered,
is in docs/DECISIONS.md "Interception coverage".
- Frames instead of the render-block parser. The first design inferred "render blocks" from the call stream (a parser guessing where a path or a paint starts and ends) and showed parser snapshots in the old inspector. I dropped it rather than finish it. A frame boundary is something the inject script observes directly (the animation-frame flush), so nothing is guessed; history, replay, lint and export all use that one unit, and the inspector now reads the real replay context instead of parser state. The cost: a frame replays from a default context, so state set in earlier frames is not carried over (Known limitations). The ledger entry is B2 in docs/LEDGER.md.
- Storage: history stays in the service worker's memory. Chrome stops an
idle extension worker after 30 s. I chose not to persist: capture is on for
every page from
document_start, so persisting would write every canvas page's frame stream (includingfillTextcontent) to disk to protect history that is only lost while nobody is inspecting it. An open panel pings the worker every 20 s, and a worker that restarts anyway says so ("capture was interrupted", via a marker inchrome.storage.session). IndexedDB and keeping history in the panel page are rejected in docs/DECISIONS.md "Capture history and the idle-killed worker". The downside is real and listed below. - Replay from a command log, not pixel snapshots. Context state (transform, styles, clip, current path) can't be restored from pixels, so checkpoints were not built. Replaying a frame is exact for anything that doesn't depend on an unresolvable external reference.
- Compact batches over per-call messages. Per-call
postMessagewould flood the message queue, so commands are queued in the page and flushed once per animation frame as one number array plus one array for other arguments. The worker decodes a frame only when the panel asks for it. - Long-lived ports instead of one-shot
sendMessage. The relay and the DevTools connection are high-frequency and bidirectional. Only the panel reconnects with an exponential back-off (src/panel/useWorkerPort.ts). The content script has no reconnect loop, so a page that stopped drawing does not keep waking the worker: it re-dials on its next batch, or when the worker asks it to because a panel opened on its tab. - Performance. I measured before changing anything and kept only what the numbers supported; the table of what each change did, and what I tried and dropped, is in docs/PERF.md. Headline, from that file (one load: 7,034 mostly numeric commands per frame, Chromium 156, 3 × 30 s medians): page frame time 3.73 ms without the extension, 13.16 ms with the first version, 4.07 ms now (+9 %); worker CPU per page frame 11.8 → 1.55 ms; command-list rows in the DOM 7,034 → 36; frame switch 189 → 21 ms. Strings, gradients and images were not measured.
From docs/LEDGER.md: its open items, in plain words.
Capture
- History recorded while DevTools is closed is lost once the tab stops drawing for 30 s and the worker is stopped; the panel then says "capture was interrupted".
- The history cap is per tab: N drawing tabs can hold N × 32 MiB.
- Not intercepted: getters, contexts inside workers (including
transferControlToOffscreen()), iframes (all_frames: false). - A page restored by Back from the browser's back/forward cache records nothing more until it is reloaded, and its canvas list is lost (a known bug, pinned by a failing test).
- Any script on the page can still post well-formed commands it never drew,
or pause its own capture; that only affects its own tab. The worker drops
and counts malformed messages, but the panel does not show the count. A real
command over the caps (a
fillTextover 10,000 characters, an imagedata:URL over 8 Mi characters, a dash list over 1,024 entries) is dropped too.
Replay
- A frame replays from a cleared, default context: state and pixels from earlier frames are not carried over.
drawImagefrom another canvas is not replayed: the panel draws nothing for it and lists "drawImage from a canvas is not replayed". Apps that keep layers or sprites in a second canvas (Excalidraw's scene, for one) replay empty or incomplete.- Gradient and pattern styles replay as
transparent(so the inspector shows transparent too); methods and setters added after the replay list was written (roundRect,reset,fontKerning, …) replay asunknown-commandwarnings. - The replay canvas has the source canvas's size at its first recorded command; a later resize is not seen.
- Video sources and images that failed to load are re-fetched on every replay (S10).
Lint and inspector
- Only the selected frame is linted, so timeline markers cover frames you viewed or followed live, not the whole history. Rules see one frame, and L001 judges the canvas as it was at its first draw.
Import
- One imported recording at a time, one canvas per file. Gradient, pattern,
Path2DandImageDatarefs replay as warnings, and only inlinedata:image/*images replay from a file. A recording larger than Chrome's 64 MiB port-message limit may never arrive from the worker (not tested).
Performance
- With the panel open in live mode the bench page drops from about 245 to about 100 fps; not isolated (the panel is a second page in the same headless browser). The panel itself uses 43–46 ms/s of main thread in live mode, against 33 before the lint pass, which lints the newest frame once a second.
- One command step in the panel takes 5.3–5.6 ms, against 3.7 ms before the lint pass and the UI redesign; measured per commit, spread over rendering, no single cause (docs/PERF.md).
- The bench covers one numeric-heavy load;
fillText, gradient and image-heavy pages are not measured. Pages that build manyPath2Dobjects pay for recording each path's construction (a probe: 1,000 paths of 200 segments took 22 ms to build instead of 5).
Not tested: screen-reader output, a real mouse drag, the narrow layout beyond screenshots, export time and memory on a full 32 MiB history.
Stack traces (which line of page code made a call) and pixel-to-call lookup (click a pixel, find the command that drew it). Neither is started.
docs/AUDIT.md is the pre-1.1 audit, kept as history; docs/LEDGER.md has the current status of every item in it. Before/after screenshots of the panel are in docs/screenshots/ and the audit behind them in docs/UI-AUDIT.md.


