Compressing the Wire: A 94% Payload Reduction in React Flow
Node-graph editors — n8n clones, pipeline builders, anything with connectors and edges — have a quiet performance problem. The thing the user drags around the canvas is a rich UI object, and naive implementations persist that object verbatim. Save a moderately complex workflow and you are writing kilobytes of measured widths, port geometry, drag state, and React internals into your database. On IntegrateX, engineering a clean serialization boundary cut that payload by 94%.
Where the bytes go
A single React Flow node is not your data. It is your data wrapped in everything the renderer needs: position, measured dimensions, selected and dragging flags, handle definitions, z-index, and whatever view state your custom node component attached. Multiply that by every node and edge, serialize the whole graph, and the transport payload is mostly things that should never have left the browser. None of it is meaningful to the backend, and all of it is dead weight on every save, load, and sync.
The Serialization Adapter pattern
The fix is an explicit boundary between two models that the naive design conflates: the render model (what React Flow needs) and the transport model (what your database and wire actually require). An adapter translates between them in both directions, and nothing crosses the boundary without passing through it.
// render model (React Flow) → lean transport record
export function toRecord(node: FlowNode): NodeRecord {
return {
id: node.id,
type: node.type,
x: round(node.position.x), y: round(node.position.y),
config: node.data.config, // the only domain data that matters
}; // no measured, no flags, no handles
}Render model vs transport model
The inverse, fromRecord, rehydrates a transport record back into a full render node — recomputing the view state the renderer needs from defaults and layout rules rather than from storage. The principle: anything that can be derived should never be stored. Measured dimensions are derived. Selection is ephemeral. Handle geometry is a function of the node type. Persisting them is persisting a cache.
Zustand as the render store
Keep one store — Zustand is ideal here — as the single owner of the render model. The canvas reads and writes the rich nodes there with zero ceremony. Serialization happens only at the boundary: on save, map the store through toRecord; on load, map records through fromRecord back into the store. The adapter is the only code that knows both shapes, so the rest of the application never has to.
The 94%, decomposed
The reduction is not a trick; it is the sum of removing what was never needed. Drop the measured geometry, the per-node interaction flags, the duplicated handle definitions, and the framework metadata, round the coordinates to integers, and a node that serialized to roughly a kilobyte collapses to a few dozen bytes. Across a graph of a hundred nodes the difference is the entire performance profile of save, load, and real-time sync.
Most payload-size problems are not compression problems. They are boundary problems — you are shipping the cache because nobody drew the line between the view and the record.
The full pattern, including edge serialization and migration handling, is in the IntegrateX breakdown.