Ilia Gorbunov178 downloadsSwaps large canvas images for downscaled proxies based on zoom level, removing zoom lag on image-heavy boards. Originals are never modified.
Removes zoom lag in Obsidian Canvas on boards with many large images. Your image files are never modified.
Obsidian Canvas is plain DOM. Every time the zoom level changes, Chromium re-rasterizes each image from its source resolution. A 4800×3000 photo drawn 150 px wide on screen is still recomputed from 14 megapixels — on every zoom step, for every image in view.
Measured on one real board:
| Images | 104 |
| On disk | 372 MB |
| Total | 307 megapixels |
| Decoded as RGBA | ~1.2 GB |
That gigabyte is re-rasterized as you zoom. It is the whole reason the board stutters.
It builds downscaled proxies of each image and feeds every <img> the one that
matches its real on-screen size. Zoom out and a 128 px copy is drawn; zoom in
and the original comes back.
Simulated against the same board (tiers 128/320/768/1600, headroom 1.15):
| Zoom | Megapixels before | After | Gain |
|---|---|---|---|
| 10% | 307 | 14.8 | 20.8× |
| 25% | 307 | 18.7 | 16.4× |
| 50% | 307 | 22.5 | 13.6× |
| 100% | 307 | 51.2 | 6.0× |
| 150% | 307 | 122.0 | 2.5× |
Numbers come from one vault. Yours will differ with image sizes and board layout.
Zoom is read from the transform matrix (getComputedStyle().transform)
rather than from canvas internals, so Obsidian updates do not break it.
Each image is fully decoded exactly once. Tiers are then built as a chain — 1600 from the original, 768 from 1600, 320 from 768 — which is both faster and sharper than jumping straight from 4800 to 128.
Upgrades are deferred until the gesture ends. Downgrades apply immediately because the proxies are already in memory; originals are only reloaded 200 ms after you stop moving, so heavy files never load mid-zoom.
DOM reads and writes are split into phases. All sizes are measured first, then all swaps applied — otherwise the browser would recompute layout once per image.
Node widths are cached in a WeakMap and refreshed through a
ResizeObserver, so offsetWidth is never touched during a frame.
Prewarm walks the board's JSON, not the DOM. Obsidian unmounts distant nodes, so they are not in the DOM to be found; this way proxies are ready for images you have not scrolled to yet.
Files are read through vault.readBinary, falling back to fetch for
external URLs.
Proxies live in IndexedDB inside your Obsidian profile — not in your vault.
This matters: on a large vault the proxy set runs to roughly 100 MB. Kept under
.obsidian, all of it would be pushed through Obsidian Sync, count against your
storage quota, and — because cache keys include each file's mtime — quite
possibly arrive useless on the other machine anyway. It is derived data, and it
is cheaper to rebuild than to carry.
Memory is bounded separately. Every object URL keeps its blob alive, so entries are evicted least-recently-used once the RAM budget is exceeded; anything currently on screen is pinned and never revoked. Evicted proxies are reloaded from IndexedDB without re-decoding.
Both budgets are configurable, and cache keys invalidate themselves when you edit an image.
Copy main.js, manifest.json and styles.css into
<vault>/.obsidian/plugins/canvas-image-lod/, then enable the plugin under
Settings → Community plugins.
OffscreenCanvas needs Safari 16.4+, and decoding large images on a
phone risks the OS killing the WebView. Mobile support needs its own work.No build step — main.js is plain CommonJS, edit and reload.
cd test && npm install && npm test
Three suites:
loadtest.js — the module loads, onload/onunload survivebindtest.js — deliberately broken canvas leaves never break plugin loadingcachetest.js — IndexedDB round trip and pruning, memory eviction and
pinning, the export wrapper, legacy cache removal, settings renderingsimulate.js measures the level-of-detail win against a real board; edit the
vault path at the top before running it.
MIT