Feed Exchange moves well data between Rogii's platform and Petrel Studio. Before a run, the user picks what to send in two trees: one shows the Petrel Studio project, the other the Rogii project. A project can hold thousands of wells, each with folders and attributes under it. I built both trees in January 2026.
On projects with more than 7,000 wells, the trees stopped keeping up. Ticking the root of Well Attributes took 1.3 seconds to show on screen. A few clicks on folders in a row kept the main thread blocked for 18 seconds in total.
Five causes
The tree was already virtualized and drew only the rows in view. All five causes were in the work that runs around a click.
- A hook watched a 770 KB object. Every row in the process list asked whether its process was being saved. To answer, the hook selected the whole variables of the pending save, about 770 KB, and deep-compared them on every mutation cache event. It now selects only the process id.
- Every refetch compared 3.5 MB. TanStack Query keeps unchanged parts of a response by comparing the old and new data. On a 3.5 MB tree payload, that comparison ran on every refetch. I replaced it with structural sharing per field, on top of a deep-equal check that allocates nothing.
- Two trees, two queues. Each tree batched its own clicks for 400 ms and sent its own PATCH, and the regular poll kept refetching the 3.5 MB tree while saves were in flight. Every save also makes the backend touch the project, which the app then merges on the main thread. Both trees now share one queue: it waits a second after the last click, sends one request at a time with only the latest selection, refetches once per burst, pauses polling while a save is pending, and keeps the Run button disabled until it lands.
- 551,000 strings per rebuild. The grouped view (wells × attributes) rebuilt every leaf id on each rebuild. The ids are now computed once, and the check for "fully, partly or not selected" allocates nothing and stops at the first answer.
- The same extraction three times. Turning the tree state
into a list of selected ids took about 330 ms, and a root toggle ran it
three times. The result is now cached per payload in a
WeakMap, seeded while the selection is assembled, and the hooks share one selection context.
The same change fixed a smaller bug: after switching projects inside the app, the Rogii tree's store still held the previous project's tree. It is now cleared before the new project loads.
Result
| On a project with 7,000+ wells | Before | After |
|---|---|---|
| Tick the Well Attributes root, until paint | 1.3 s | 280 ms |
| A sequence of folder clicks, main thread blocked | 18 s | no long tasks |
| Select all 7,155 wells, until paint | not measured | 9 ms |
Numbers from the commit that shipped the fix.
Keeping it fixed
- Benchmarks.
yarn benchruns Vitest benchmarks for selection on a 7,000-well tree. Older ones from January cover building and expanding the tree itself: expanding the root of a 500,000-node tree takes about 6 ms, building it with every node expanded about 280 ms. - Browser tests. A Playwright suite clicks through the checkbox logic of both trees in both view modes and asserts on the PATCH requests that go over the wire, so a regression in batching fails a test.
- Tests that can fail. A test written with a coding agent can pass whether the code is right or not. In September I went through the tree tests and fixed the ones that could not fail, checking each fix against a deliberately broken copy of the source. One cause turned out to be global: a request with no mock handler went to the network, the component showed its error state, and the test could pass for the wrong reason. Such a request now fails the test.
What came after
In September a teammate replaced react-vtree with a flat windowed list, for projects where a fully expanded tree reaches about 650,000 rows. That rewrite was his work. My part of the tree story is the selection performance above and the benchmarks and tests around it.