Why Figma files load slowly
A Figma file can easily grow into a sprawling project: dozens of pages, hundreds of frames per page, and a web of dependencies connecting components, styles, and variables across those pages. For years, loading a file meant pulling down the entire tree of nodes, whether or not the user actually needed most of it.
Usage data showed that most people treated files as projects and rarely visited every page in a single session. That observation drove a shift in loading strategy: fetch the page a user is looking at, and bring in the rest on demand. Figma has been applying dynamic loading to view-only files and prototypes already; the target now was the 70% of daily loads that come from editable files.
The payoff for the biggest files is significant. Among the slowest 5% of page loads, load times have dropped by 33%.
Read dependencies: what the canvas actually needs
A Figma file is a tree of nodes, but nodes on one page can reference nodes on other pages. These read dependencies are what a client must download to render a given page correctly. Common cases:
- Instances and components: an instance points at its backing component, which may live on any page.
- Styles: Figma implements styles (like a fill color) as user-invisible nodes. A frame applying a style needs that style node downloaded.
- Variables: a node using a variable for, say, font size must fetch the variable node to resolve the raw numeric value.
Figma's multiplayer system tracks these edges in an in-memory graph called QueryGraph. For view-only users, that graph drives page-by-page loading on the canvas, and in prototypes it loads frames individually, preloading only those within a few navigation steps. Editing complicates this model: an edit on one page must propagate changes to dependent nodes that haven't been loaded yet.
Write dependencies: the inverse problem
Safely editing a file demands more than read dependencies. Because Figma caches the output of expensive calculations, changing a source node requires updating every node that derived data from it. For instance, a text layer has a read dependency on a text style; the style, in turn, has a write dependency on the text layer—the cached glyph data must be recomputed and pushed when the style changes.
Two representative write dependencies cross page boundaries:
- Components to instances: editing a component propagates cache updates to any instances on other pages. Text styles and variables follow the same pattern.
- Auto layout chains: resizing a component on one page may resize an instance elsewhere, which can cascade to the instance's siblings inside an auto layout frame.
Alternatives considered
The obvious approach was extending QueryGraph to track write dependencies, mirroring the viewer-side logic. But editing carries more risk than viewing: missing a read dependency in a view-only session shows an incorrect frame; missing a write dependency during editing risks silently corrupting file data across pages. The team evaluated two simpler options before committing.
Backfill after load
This scheme loads the first page exactly as the viewer path does, then quietly downloads the rest of the file in the background. The drawback is that the file stays view-only until that backfill completes. Queue management also gets awkward—if a user jumps to another page, it has to be bumped to the front of the download queue, all without causing frame hitches.
Pull-based reactive core
Write dependencies exist because derived-data caches are updated with a push model. Migrating to pull-based reactive propagation would remove the need to pre-download dependent nodes before edits. That would be a re-architecture of the entire data pipeline—more investment than this project could justify, especially when the goal was to ship measurable load-time wins quickly.
Choosing write dependencies over larger rewrites
We weighed two other approaches before settling on our final design. Delayed editing with backfill would have been simpler to build but wouldn't have cut client-side memory usage. A full data model overhaul would have eliminated write dependencies entirely but would have required a much longer implementation timeline. Write dependency computation offered the best balance of performance, feasibility, and user experience.
In practice, this means the first page plus all of its read and write dependencies load up front. Navigating to another page fetches that page's read and write dependencies on demand. The rest of the file stays off the client until it's actually needed.
Encoding both read and write edges
QueryGraph originally captured only read dependencies, which was sufficient for viewers and prototypes. Editors need to know how writes propagate too, so we redesigned the underlying structure as a bidirectional graph. This lets us quickly resolve both read and write dependency sets for any given node during a dynamic load.
Some write dependencies are explicit, like the componentID foreign key on an instance node pointing to its backing component. Others are implicit. Auto layout is a good example: editing one node in an auto layout frame can shift its neighbors automatically, even though those nodes don't reference one another directly in the file. We encoded these implicit relationships as a new edge type in the graph.
Keeping multiplayer in sync with partial state
Multiplayer holds the complete file representation and the QueryGraph in memory so it can serve dynamic loads and edits. For each load, the client names its desired initial page and QueryGraph computes the minimal file subset it needs. When a collaborator makes an edit, the server decides which sessions receive that change based on each session's subscription set. A user with only page one loaded won't get updates about a rectangle's fill on page three, because that rectangle isn't reachable from their subscribed set.

Edits can change the graph's dependency edges, which in turn affects what other users receive. Swapping an instance to a different component can make that component's subtree newly reachable to collaborators, even if the acting user never touched any node in that subtree. The system has to recognize that shift and push the relevant data accordingly.
Verifying dependency completeness with shadow multiplayer
Dynamic loading has to produce byte-for-byte identical results to a full file load. If a write dependency is missing, downstream derived data can go stale, manifesting to users as instances detached from their components, incorrect auto layout geometry, or text rendering without proper fonts. These look like serious product bugs rather than architectural edge cases.
Our validation strategy was a shadow mode in multiplayer. For an extended period, the server tracked which page each user was on and computed write dependencies exactly as it would under dynamic loading, but with all runtime behavior unchanged. Any edit arriving from a client that fell outside the computed write dependency set raised an error.
Shadow mode paid off immediately. We surfaced a cross-page, recursive write dependency involving frame constraints and instances that our original enumeration missed. Without it, edits could have left layout computations incomplete. The framework let us catch the gap, add regression tests, and extend QueryGraph before any user hit the bug.
Optimizing the dynamic load path
Full file loads previously streamed a raw encoded file from storage straight to the client. Dynamic loading requires multiplayer to decode the file into memory first, so it can determine which segments to send. That decode sits in the critical path and was initially a bottleneck.
We shaved time off in three places. First, the backend starts preloading a file as soon as the initial GET request arrives, even before the client opens its WebSocket connection to multiplayer. That head start trims 300–500 ms off the p75 load time. Second, we made decoding parallel: by persisting raw offsets in the file format, we can split decoding work across multiple CPU cores. Serial decoding could take over five seconds on our largest files; the parallel approach cuts that by more than 40%. Third, the client now defers materializing instance sublayers for pages the user hasn't opened, since those layers can be derived from the backing component and user overrides. Removing the assumption that every node is fully materialized at load required updating dozens of subsystems to support lazy materialization.
Measured results
We rolled dynamic page loading out over six months, gated behind A/B tests with automated telemetry throughout. The final numbers held up well:
- 33% faster loads on the slowest, most complex files, despite an 18% year-over-year increase in file size
- 70% reduction in client-side node count through selective loading
- 33% drop in users hitting out-of-memory errors
With files continuing to grow, dynamic page loading now anchors our broader performance work for load times and memory usage alike.



