Rendering the Infinite: Workflow Canvas Optimization

TL;DR: Visual automation is great until your workflow hits 50+ nodes and the browser starts to stutter. At ByteChef, we solved canvas performance by leaning on what React Flow gives you for free, and being surgical about what actually needs to re-render or re-layout.
When building a workflow editor, the canvas is the heart of the user experience. But as a frontend engineer, the canvas is also your biggest performance bottleneck. Every node is a React component, every connection is an SVG, and each state change has the potential to trigger a cascade of recalculations.
React Flow
The first thing we didn't have to build was viewport culling. React Flow (@xyflow/react) only renders nodes that are currently within the viewport, and nodes outside the visible area are not mounted in the DOM at all. This is the single biggest performance win on an infinite canvas, and it comes for free.
What this meant for us: we didn't need to write custom visibility logic. Instead, we focused our optimization efforts on what happens inside the viewport - specifically, preventing unnecessary re-renders and layout recalculations for nodes that are visible.
Prevent Layout Thrashing
The most impactful custom optimization we built was structural fingerprinting for the layout engine.
ByteChef uses Dagre for automatic node layout. Every time workflow tasks change, useLayout re-runs the Dagre algorithm and repositions all nodes. The problem: "tasks changed" could mean anything from adding a new node to typing a single character into a parameter field. We definitely don't want to re-layout the entire canvas because a user typed in a text input.
The solution was a custom equality function that subscribes to workflow tasks using useStoreWithEqualityFn from Zustand:
// Only re-run layout when the *structure* changes, not the parameters
useStoreWithEqualityFn(
useWorkflowDataStore,
(state) => state.workflow.tasks,
(previousTasks, nextTasks) =>
getTasksStructuralFingerprint(previousTasks) ===
getTasksStructuralFingerprint(nextTasks)
);The fingerprint encodes task name, type, nesting depth, and subtask counts, but deliberately ignores parameter values. This means the entire layout pipeline is skipped while a user is editing node configuration. Layout only recalculates when the graph structure actually changes: a node is added, removed, or its type changes.
Preventing Cascading Re-renders
By default, React re-renders children when a parent updates. On a canvas with dozens of nodes, a single drag event can cascade into re-renders across the entire tree.
memo and useShallow
We wrap WorkflowNode in React.memo:
export default memo(WorkflowNode);No custom comparator is needed here. The reason: all store subscriptions inside WorkflowNode already use useShallow from Zustand, which does a shallow equality check on the selected state slice. React Flow also passes stable node props so React's default prop comparison in memo is sufficient. Each node only re-renders when something it actually cares about changes.
The useShallow pattern is applied consistently across every store subscription in the canvas:
const {currentNode, setCurrentNode, workflowNodeDetailsPanelOpen} =
useWorkflowNodeDetailsPanelStore(
useShallow((state) => ({
currentNode: state.currentNode,
setCurrentNode: state.setCurrentNode,
workflowNodeDetailsPanelOpen: state.workflowNodeDetailsPanelOpen,
}))
);This ensures that updating an unrelated part of the store doesn't re-render nodes that don't need it.
Async Layout
Dagre layout runs asynchronously. This introduces a classic race condition: if a user rapidly adds nodes, multiple layout calculations can be in-flight simultaneously, and a slow earlier result could overwrite a faster later one.
We handle this with a cancellation flag pattern:
let isCancelled = false;
getLayoutElements(...).then((result) => {
if (isCancelled) return;
// apply layout
});
return () => {
isCancelled = true;
};The effect cleanup sets isCancelled = true, so any in-flight layout that resolves after the effect re-runs is silently discarded. Only the most recent layout result is ever applied.
Animated Positions and the Panel-Resize Shortcut
When nodes move to new layout positions, hard-snapping them is jarring. We animate nodes from their current visual positions to their new targets using a cancelAnimationRef
if a new layout resolves while an animation is running, the previous animation is cancelled before the new one starts.
One specific optimization worth highlighting: panel-resize shifts When the side panels open or close (node details, data pills, copilot), we don't re-run the full Dagre layout. Instead, we calculate the width delta and shift all node X positions by half that amount:
// Instead of re-running dagre, just translate existing nodes
const xShift = panelWidthDelta / 2;
nodes.map((node) => ({
...node,
position: {...node.position, x: node.position.x + xShift},
}));This keeps the canvas centered without the cost of a full layout recalculation, and it still animates smoothly.
Canvas Configuration as a Performance Decision
Finally, some intentional React Flow configuration choices:
<ReactFlow
nodesConnectable={false}
zoomOnDoubleClick={false}
zoomOnScroll={false}
panOnScroll
maxZoom={1.5}
minZoom={0.001}
/>Disabling zoomOnScroll and routing scroll to panning instead is both a UX and performance decision. Scroll-to-zoom on a complex canvas triggers continuous viewport recalculations. Capping maxZoom at 1.5 also prevents the canvas from entering a zoom level where rendering costs spike without adding usability.
The Result
With these optimizations in place, ByteChef's canvas remains fluid across large workflows. The key insight across all of them is the same: be precise about what actually needs to change. React Flow handles the rendering boundary. Structural fingerprinting handles the layout boundary. useShallow and memo handle the component boundary. Each layer does its job without over-triggering the one below it.
Performance on an infinite canvas isn't one big fix, but a series of small, precise decisions about what work to skip. Try how it works here!
Subscribe to the ByteChef Newsletter
Get the latest guides on complex automation, AI agents, and visual workflow best practices delivered to your inbox.