lundie.io Get In Touch

Phase 5: Performance Crisis

22 August 2025

August 22nd. The tree was functional – optimistic updates, API sync, drag-and-drop all working. Then I actually used it. Clicking the drag handle and waiting nearly a second before drag mode engaged made it feel seriously broken. Obviously, this wasn't a polish problem; it was a usability blocker.

The Investigation

The lag was specific: click the drag handle, nothing happens, then about a second later the node lifts. Once dragging started, movement was smooth. That distinction mattered – it pointed to drag activation rather than render cost or DOM thrashing. I worked through five approaches, none of which fixed the core issue.

All of this was local. I reset the commits deliberately, documenting what had been tried before moving on – which is why c32d085 (the 22 August commit) is the mutation queue work rather than any of this.

Attempts 1–3: Render Optimisation

The first theory was excessive re-renders cascading into drag startup cost. I added React.memo to BaseNode, NodeComponentFactory and SortableTreeNode, with a custom comparator on the tree nodes to avoid re-renders when unrelated state changed:

SortableTreeNode – custom memo comparator
1
2
3
4
5
6
7
8
9
10
export const SortableTreeNode = memo(SortableTreeNodeComponent, (prev, next) => {
    return (
        prev.item.id === next.item.id &&
        prev.item.nodeData === next.item.nodeData &&
        prev.item.children === next.item.children &&
        prev.isRoot === next.isRoot &&
        prev.pendingNodes === next.pendingNodes &&
        prev.onAddChild === next.onAddChild
    );
});

Alongside that: stabilising props with useCallback, switching to functional setState to avoid stale closures, memoising getAllItemIds. A follow-up pass memoised drag-related objects inside SortableTreeNode and wrapped the drag handle component itself.

The React DevTools Profiler showed this working – individual node render times dropped from 2–10ms to under 0.1ms. The tree felt noticeably snappier for typing and expanding nodes. The drag activation lag didn't move.

An honest note on the code

Some of what landed during this pass had issues. The most persistent: inside useNodeCapabilities, functions were memoised with useMemo instead of useCallback.

useNodeCapabilities – useMemo used where useCallback belongs
1
2
3
4
5
6
7
8
9
// ❌ What landed – semantically wrong
const canPerformAction = useMemo(() => (action: NodeAction): boolean => {
    return canPerformActionUtil(nodeType, action, customCapabilities);
}, [nodeType, customCapabilities]);

// ✅ What it should be
const canPerformAction = useCallback((action: NodeAction): boolean => {
    return canPerformActionUtil(nodeType, action, customCapabilities);
}, [nodeType, customCapabilities]);

Both produce a stable function reference – but useMemo is for computed values, useCallback is for functions. The distinction matters for readability and for how linters reason about the code. This slipped in during a performance pass and wasn't caught.

The custom memo comparator on SortableTreeNode had a subtler problem: reference equality checks on item.children and item.nodeData. If TreeDataAdapter.buildTreeFromData creates new array and object references on each call – which it does – those checks fail every time and memo never short-circuits. The render improvement was real, but it likely came from the useCallback prop stabilisation rather than the comparator itself.

None of the React.memo wrappers survived as the components grew. Useful to notice in hindsight.

Attempts 4–5: Library-Level Debugging

With rendering ruled out, I looked at the library itself. Attempt 4 memoised the drag handle's event handlers and wrapped handleDragStart / handleDragEnd in useCallback. Attempt 5 switched collision detection from pointerWithin to closestCenter – pointerWithin had been adopted a few weeks earlier for the insertion-line drop system. The theory was that its per-frame zone calculations were expensive at startup.

Neither changed anything. The lag held at roughly one second.

What the Profiler Actually Showed

Chrome DevTools' Performance tab was the final check: no layout thrashing, no long JavaScript tasks blocking the main thread, paint operations normal – but the drag event itself was delayed. The bottleneck was inside @dnd-kit's sensor initialisation. On pointer down, it computes bounding rects for every droppable element before activating. With 20-plus nodes each carrying before/after insertion indicators, that startup sweep was the full second.

This wasn't an API issue or a data problem. It was purely a UI scaling issue – and fixing it properly would mean switching drag libraries, which meant rewriting the tree component from scratch. I didn't want to spend more time on it at that point. The component was ~320 lines and growing; a full rewrite before the core features were in place wasn't a sensible trade.

The pragmatic decision was to move on, log the root cause and keep the render improvements that had been verified.

The Resolution – A Phase 7 Story

The solution was clear from the profiling, even if the implementation had to wait. The problem was tree size: @dnd-kit's initialisation cost scales with the number of droppable elements. A large journal loaded as a single tree would always hit that wall. The answer was to not do that – scope each tree to a single thought's subgraph rather than an entire journal.

That direction was implemented two months later in the Phase 7 workspace rebuild (016a6b0, October 2025). A filterSubgraph() method was added to TreeDataAdapter, using BFS to extract just the selected thought's hierarchy and the workspace was restructured around per-thought navigation. Individual thought subgraphs rarely grow large enough to trigger the initialisation bottleneck.

TreeDataAdapter – filterSubgraph (added Oct 2025)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
static filterSubgraph(
    nodes: ResponseNode[],
    edges: Edge[],
    rootId: string
): { nodes: ResponseNode[]; edges: Edge[] } {
    const nodeIds = new Set<string>([rootId]);
    const relevantEdges: Edge[] = [];
    const queue: string[] = [rootId];

    while (queue.length > 0) {
        const currentId = queue.shift()!;
        const outgoingEdges = edges.filter(edge => edge.from_node === currentId);
        for (const edge of outgoingEdges) {
            relevantEdges.push(edge);
            if (!nodeIds.has(edge.to_node)) {
                nodeIds.add(edge.to_node);
                queue.push(edge.to_node);
            }
        }
    }

    return {
        nodes: nodes.filter(node => nodeIds.has(node.id)),
        edges: relevantEdges,
    };
}

The drag lag was a real problem with a known cause. The fix just belonged to a different phase.

Optimisation Attempts
5
Render Time Before
2–10ms
Render Time After
<0.1ms
Drag Lag Fixed

Key Commits from This Phase

Web c32d085 2025-08-22
[Feat/Refactor] Tree mutation queue, toasts and bug fixes

The performance investigation was local and reset – documented before the commit was cleared so it could be reimplemented. Render improvements were reintroduced incrementally in subsequent commits; the drag lag fix landed in Phase 7 (016a6b0).

Get In Touch

Prefer using email? Say hi at hello@lundie.io