Skip to content

Research: a lazy per-row reactive graph to cut the 10k mount #22

Description

@thedanchez

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions