Summary
Research item, not a bug. At 10,000 nodes and 10,000 edges the 10k mount is the one benchmark row where Solid Flow trails the other implementations, and the reason is structural: the flow builds its whole per-row reactive graph up front, so that every later interaction is cheap. A lazier row graph, built as rows are needed, is the one architectural lever left on mount. This issue records the numbers, the shape of the idea and the trade-offs, so the decision can be made deliberately later.
Where mount stands (2026-09-26, private head-to-head bench, production builds, 10k nodes + 10k edges)
|
Solid Flow |
React Flow 12.12 |
Svelte Flow 1.7 |
Mount to nodesInitialized, ms |
2,294 |
1,868 |
1,352 |
| Heap on mount, MB |
654 |
253 |
451 |
| Node drag, script ms over 60 moves |
31 |
10,634 |
495 |
| Selection drag (30 nodes), script ms |
107 |
10,592 |
544 |
Per node the mount is about 0.22 ms including the DOM, the ResizeObserver measuring pass and the row graph; graphs of a few hundred nodes mount in tens of milliseconds where the three are indistinguishable. Rows arriving through createNodeStore / createOptimisticNodeStore or already seeded mount 100 to 150 ms faster than plain arrays.
What the mount is made of
Every node gets, at mount: a row store (a keyed projection joining the user row with the measurements root and the overlays), a user-snapshot memo and a key-set memo, two presence projections (unmeasured, selected), a geometry-feed entry, its DOM subtree, and one ResizeObserver observation. Every edge gets a layout projection, a lookup holder, a connections memo, a markers memo and a selected-presence projection. This is what makes a drag frame 0.09 ms per move and keeps every interaction row 10x or more ahead, and it is also most of the heap gap.
The idea
Build the per-row reactive graph lazily: rows that are off-screen (the culling record already knows which) would get a lightweight placeholder, and the full row store, memos and DOM would be created on first need (entering the viewport, being selected or dragged, being an edge endpoint that is on screen, or being read through nodeLookup). The user-facing contracts stay: internalNodes[id] and nodeLookup.get(id) would materialize on read.
Trade-offs to weigh before doing it
- The first interaction with a lazy row pays the creation cost at gesture time, which is exactly where the current design is fastest. A pan across a dense region would materialize rows during the pan.
nodesInitialized and fitView need every node's dimensions; lazy rows would need a measurement strategy for off-screen nodes (measure without mounting, or defer fitView to what is known).
- Edges need both endpoints' geometry; an on-screen edge whose endpoint is off screen forces that endpoint's row.
- Headless use (
createFlowState without a DOM) has no viewport; the lazy path must degrade to eager there.
- SSR and hydration render every row today; a lazy client would have to claim server rows without recreating them.
Related measurements
Acceptance if pursued
Mount to nodesInitialized at 10k within 20 percent of Svelte Flow, with every interaction row of the head-to-head bench inside its current band (the guardrail the performance rounds run under), and no change to the public read contracts.
Summary
Research item, not a bug. At 10,000 nodes and 10,000 edges the 10k mount is the one benchmark row where Solid Flow trails the other implementations, and the reason is structural: the flow builds its whole per-row reactive graph up front, so that every later interaction is cheap. A lazier row graph, built as rows are needed, is the one architectural lever left on mount. This issue records the numbers, the shape of the idea and the trade-offs, so the decision can be made deliberately later.
Where mount stands (2026-09-26, private head-to-head bench, production builds, 10k nodes + 10k edges)
nodesInitialized, msPer node the mount is about 0.22 ms including the DOM, the ResizeObserver measuring pass and the row graph; graphs of a few hundred nodes mount in tens of milliseconds where the three are indistinguishable. Rows arriving through
createNodeStore/createOptimisticNodeStoreor already seeded mount 100 to 150 ms faster than plain arrays.What the mount is made of
Every node gets, at mount: a row store (a keyed projection joining the user row with the measurements root and the overlays), a user-snapshot memo and a key-set memo, two presence projections (unmeasured, selected), a geometry-feed entry, its DOM subtree, and one ResizeObserver observation. Every edge gets a layout projection, a lookup holder, a connections memo, a markers memo and a selected-presence projection. This is what makes a drag frame 0.09 ms per move and keeps every interaction row 10x or more ahead, and it is also most of the heap gap.
The idea
Build the per-row reactive graph lazily: rows that are off-screen (the culling record already knows which) would get a lightweight placeholder, and the full row store, memos and DOM would be created on first need (entering the viewport, being selected or dragged, being an edge endpoint that is on screen, or being read through
nodeLookup). The user-facing contracts stay:internalNodes[id]andnodeLookup.get(id)would materialize on read.Trade-offs to weigh before doing it
nodesInitializedandfitViewneed every node's dimensions; lazy rows would need a measurement strategy for off-screen nodes (measure without mounting, or deferfitViewto what is known).createFlowStatewithout a DOM) has no viewport; the lazy path must degrade to eager there.Related measurements
measuredon adopted rows so the measuring pass leaf-writes: 2.3 s to 2.2 s (round 38).Object.keys/ spread of a store object materializes a presence node per key for the reader — O(keys) memory per reader, retained on the store; rc.8 subscribed to one key-set node solidjs/solid#3664), which should reduce mount heap without library changes; re-measure after the bump before deciding anything here.Acceptance if pursued
Mount to
nodesInitializedat 10k within 20 percent of Svelte Flow, with every interaction row of the head-to-head bench inside its current band (the guardrail the performance rounds run under), and no change to the public read contracts.