[ EXECUTIVE TEARDOWN // TL;DR ]
- Separate the render model (UI) from the transport model (DB) with an adapter that owns the only conversion.
- Anything derivable — dimensions, selection, handle geometry — is recomputed on load, never stored.
- The 94% reduction is the sum of removing framework metadata and rounding coordinates, not a codec trick.
- Most payload-size problems are boundary problems: you're shipping the cache because no line was drawn.
Rich UI objects are great for rendering and lousy as records. In IntegrateX — a node-graph automation tool — the node you drag around the canvas is packed with measured geometry, hover/selection flags, handle maps, and framework internals. Persist that verbatim and every save ships kilobytes of cached view state, sync gets chatty, and real-time edits run into backpressure. I put a Serialization Adapter at the boundary, kept only the domain fields, and dropped the UI scaffolding. Net: 94% smaller payloads and predictable sync.
Two models, one boundary
The naive approach fuses two things that must stay separate: the render model (what the UI needs to draw) and the transport model (what the wire and database require). The adapter translates both ways, and nothing crosses without it. This is the Data / Serialization Adapter in the pattern I call Trinity Architecture: Presentation renders and dispatches only, a Reactive State / Orchestration layer owns the runtime truth and optimistic updates, and the Adapter shapes lean records for the wire. No layer talks past its neighbor — the UI never formats DB schemas, and the adapter never pokes UI state directly.
adapter.ts
// render model → lean transport record (and back)
export const toRecord = (node: FlowNode): NodeRecord => ({
id: node.id,
type: node.type,
x: Math.round(node.position.x),
y: Math.round(node.position.y),
config: node.data.config, // the only domain data that matters
});
export const fromRecord = (r: NodeRecord): FlowNode =>
hydrate(r, defaults(r.type)); // recompute view state, don't store it
Derived, never stored
The lever behind the compression is simple: anything that can be derived must not be stored. Dimensions come from layout. Selection is ephemeral. Handle geometry is a function of node type. Persisting them is persisting a cache. In IntegrateX, the orchestrator (Zustand in the middle of my Trinity split) recomputes those bits on load and on change, so the transport stays lean while the UI stays reactive.
The 94%, decomposed
This wasn't a clever codec; it was subtraction. Drop measured geometry, per-node flags, duplicated handles, and framework metadata. Round coordinates to integers. A node that used to serialize to roughly a kilobyte collapses to a few dozen bytes. Multiply by a hundred-node graph, and you reclaim the budget for save, load, and real-time sync — fewer bytes on the wire, fewer renders, less state-synchronization lag.
Most payload-size problems aren't compression problems — they're boundary problems. You're shipping the cache because nobody enforced the line between the view and the record.
For the state-management side of the same project — optimistic updates, sync ordering, and throttled broadcasts — see Compressing the Wire; the system is IntegrateX. I reach for this boundary discipline by default now — the engineer who draws the line between view and record early is the one whose sync layer still behaves at a hundred nodes.
~/keep-reading
- 8 min readCompressing the Wire: A 94% Payload Reduction in React FlowNode-graph editors serialize enormous JSON. A custom Serialization Adapter pattern that separates the React Flow render model from the transport record cut IntegrateX payloads by 94%.
- 6 min readCutting a Payload 94% With Custom Serialization PatternsI cut a React Flow agent-graph payload 94% without losing a node — not with gzip, but by shaping a custom serialization format around the data: schema, not prose.
- 7 min readMongoDB Aggregation Pipelines: Stage Order Is the WinProfiling a clinical API taught me MongoDB aggregation pipeline optimization lives or dies on stage order: $match first on indexes, $lookup join performance, then explain.
YK
Yaseen Khatib · MERN + AI Architect
Ships autonomous AI products solo — five in the last twelve months. More about Yaseen →
Need an engineer who can build this?
I'm Yaseen Khatib — a Senior Full-Stack AI Engineer (MERN + TypeScript) who ships production AI systems solo. Open to senior and lead roles, remote or on-site.
Get in touch →See what I've shipped
Originally published at yaseenkhatib.streamerosai.com/blog/custom-serialization-adapters/.
Top comments (0)