7 min read
What 40,000 nodes taught us about Fanta's canvas
We opened a 128 MB, 40,141-node Figma file in Fanta, then followed the copies, image decodes, saves, and agent responses that made large designs hard to use. Here are the measurements and the work still left.
On this page
Fanta build log · Part 3 of 4 · 9 September test
The first time we opened a 128 MB community design in Fanta, the canvas eventually answered. That felt like progress until we added one 10×10 rectangle. The app's resident memory went from 1,960 MiB to 5,788 MiB after that edit and its autosave. One rectangle had apparently cost about 3.8 GiB.
This is the less photogenic part of building a design editor in public. A small sample makes every architectural choice look reasonable. A design with 40,141 nodes and 31 pages exposes the cost. We used that file, UI3: Figma's UI Kit (Community), alongside a 9.6 MB, 29,301-node .fig to stress the canvas, importer, save path, and agent interface.
All the figures here come from our 9 September release-build rehearsal. We ran Fanta on an Apple Silicon Mac with 36 GiB of memory and drove it through its own MCP server. Nobody clicked the canvas or dragged a layer during these runs. When we say the canvas answered, we mean get_editor_state returned a node count, not that a human saw and judged the first frame.
Run / Deploy / Read
Pick the path that matches your hardware and patience
Two files, two very different waits
Imported .fig | Nodes / pages | Launch to answering canvas | Resident memory after opening |
|---|---|---|---|
9.6 MB basic.fig | 29,301 / 3 | 2.1 s, 2.4 s | 2,004 MiB, 2,173 MiB |
| 128 MB UI kit | 40,141 / 31 | 48.5 s, 48.6 s, 54.1 s | 1,952 MiB, 1,960 MiB, 1,958 MiB |
The large file took roughly fifty seconds to answer, yet both settled near 2 GiB. File size, node count, and resident memory did not scale together. Its first edit told a different story.
An earlier 30–40 second timing for the 9.6 MB file came from a debug build. These release timings are not a before-and-after speedup.
The canvas had been copying too much
Fanta's renderer works from a scene snapshot on a render thread. That keeps rendering work away from the UI thread, but it gives us a synchronization problem: when one node moves, how does the render thread learn about it? The blunt answer is to copy the scene again. With tens of thousands of nodes, doing that for each small change is a bad bargain.
We added a ScenePatch path. The canvas records a revision for the render thread's copy; if the scene can account for every change since that revision, it sends the changed transforms or nodes. If the scene instance changed, an asset or variable changed, or the change log cannot prove what happened, it falls back to a full copy. The decision in canvas.rs is intentionally conservative:
match scene.changes_since(worker.stamp.scene_revision) {
Some(delta) => SnapshotSync::Patch(delta),
None => SnapshotSync::Recopy,
}A small edit is eligible to avoid a full copy; not every edit will. The path has unit tests, but we did not physically drag a node in this session, so we have no measured drag-latency claim.
The importer needed similar restraint. One byte[] field used to become one intermediate value per byte. Shared-style resolution deep-cloned the change tree. Instance overrides rebuilt master paths repeatedly. We changed those representations and added bulk insertion for imported nodes. These are concrete changes in the large-document commit and its follow-up, but the end-to-end timings above do not isolate their individual effects.
Images should cost pixels when we draw them
A .fig may contain images for pages the user never opens. Decoding all of them while loading turns compact encoded bytes into large RGBA buffers before the canvas needs them. We changed Fanta's asset resolver to keep the encoded bytes and decode an image on first use. The opening page is prewarmed on the background load thread; other pages can pay for image decoding when first drawn.
The resolver's cache has a 1.5 GiB budget for decoded pixels and shared encoded copies. It evicts least-recently-used entries when it crosses that budget. A very large image currently in use stays, even if it exceeds the budget by itself; evicting it immediately would just decode it again on every frame. Failed decodes are cached too, so one corrupt asset does not trigger the same failure every time we repaint.
Returning to a page whose images were evicted can require decoding them again. Cache unit tests passed, and we got valid screenshots from both documents. We did not page through an image-heavy document by hand to measure first-draw stalls. Several paths changed together, so we cannot attribute the 2 GiB resting figure to lazy decoding alone.
Saving one shape must not rebuild every design
Our central promise is that a canvas edit becomes a readable source change. In a Fanta project, pages and component masters are .fnx files. Autosave makes an edit visible to Git about a second after the user stops. That promise is less useful if every tiny edit serializes every page, churns unrelated files, or sends memory through the roof.
The save path now calls Doc::clone_for_persist(), which leaves the editor's undo history and selection out of the copy sent to the background writer. Those fields are not project content; undo snapshots can be larger than the scene. The project writer fingerprints each page and component, reuses previously produced bytes for unchanged designs, and writes a file only when its bytes differ. The cache is an optimization: a cached write must produce the same bytes as a cold write.
In the release-build smoke run, a rectangle added through MCP reached disk without Cmd-S. Its diff changed exactly three files: doc/metadata.json, the page's .fnx, and its ID sidecar. The page source gained one line. That is the kind of diff a person can review, and the same file is available to an agent.
The save work did not solve the high-water mark. On the 128 MB document, the first edit and autosave moved memory from 1,960 MiB to 5,788 MiB. A later autosave brought it to 5,270 MiB, and another cycle to 5,087 MiB. This was not a fresh 3.8 GiB climb on every edit, but five gigabytes after a rectangle is still uncomfortable. We need a focused profile of document copies, write projections, and asset lifetime.
The agent had its own large-document failure
An agent cannot edit a node it cannot find. Before we bounded its responses, read_fnx_source could hand a client 43,145,940 characters from one page in a single reply. We changed the tool to return a slice: 64 KiB by default, with offsets and an explicit ceiling. In the September 9 smoke run, the same page returned 60,719 characters. For structural navigation, we direct the agent to get_editor_state and batch_get rather than ask it to read a whole enormous source file.
Then the UI kit found a different bug. Its active page had 8,920 direct children. Even with depth: 1 and geometry off, batch_get produced 967,017 bytes, over the 256 KiB response cap. The refusal suggested lowering the depth to 1, which was already the request. We had protected the client from an oversized reply but left it no way to discover those children.
We added offset and limit, defaulting to a window of 200. On a second release build, that page came back in 45 windows, with every child returned exactly once. The response includes the total and whether more remain. The unbounded call still refuses oversized answers; now it tells the caller how to page.
Where this leaves us
The September 9 run passed 18 of 18 scripted checks across agent edit, autosave, Git diff, source read, and screenshot. We also exercised twelve more design operations over MCP on both documents. The 128 MB file opened and accepted the operations we tried. It also took about fifty seconds to answer, and its first edit still pushed the process near 5.8 GiB. Both facts belong in the same story.
We are building Fanta for designs that can be opened, edited, inspected as source, and changed by agents without making the file an opaque island. Large documents force us to test all four parts together. For the product argument, read why we built Fanta on Zed. For the file format and Git loop, read how we made design files into source. The final entry will cover our Shipaton release work. This is still a build in progress, and the memory profile is one of the next honest measurements we owe it.
If you maintain a heavy design document, tell us the size and the first interaction that becomes painful. A realistic failure case is more useful than an invented benchmark. #Shipaton #BuildInPublic
Comments
Tags in this post
Keep reading
A design file you can diff is harder than it sounds
Why readable FNX was not enough: Fanta's 25,311-line diff, overwritten source edits, and the move to project files as the authority.
6 min · Sep 30, 2026
Why we built Fanta on top of a code editor
How our 2024 browser-canvas experiments and 2026 native editor fork shaped Fanta's readable design files, canvas, and agent workflow.
7 min · Sep 29, 2026
Carnegie Hall, rebuilt from a seating chart
How I turned a flat ticketing map into a complete Stern Auditorium: 2,758 seats, four tiers of gilded parapets, vaulted ceilings and hundreds of real light fixtures. I did it by building tools instead of sculpting.
33 min · Sep 16, 2026
All tags
- #ai
- #blender
- #build-in-public
- #cubemap
- #design-tools
- #diamond
- #environment-art
- #expo
- #fanta
- #file-formats
- #git
- #graphics
- #houdini
- #huggingface
- #image-generation
- #licensing
- #mdx
- #meta
- #next.js
- #ocr
- #onnx
- #open-source
- #performance
- #procedural
- #python
- #r3f
- #ray-tracing
- #react-native
- #rendering
- #replicate
- #rust
- #sam2
- #segmentation
- #shaders
- #shipaton
- #technical-art
- #three.js
- #vercel
- #wasm
- #web-worker
- #webgl
- #webgpu