Paint, don't rebuild: making a browser circuit editor feel instant
A circuit editor in the browser has two jobs that fight each other. It has to stay smooth while you drag a part around, even when the canvas holds a few hundred transistors. And it has to keep the wires looking like an engineer drew them, not like spaghetti, through every move, rotate, and delete. This is about how I got both, and the one insight that made the whole thing fast: most of the time you think you need to redraw, you don't.
The editor is SVG, no canvas 2D, no WebGL, no framework. Every component and wire is a real DOM node. That decision buys crisp vectors, hit-testing for free, and CSS for state colors, and it hands you a performance cliff: rebuilding a few hundred SVG nodes on every interaction will drop frames on a laptop and fall over on a phone.
Two functions, one rule
The whole render layer is two functions with a strict rule about who calls which.
redraw() is the honest, expensive path. It rebuilds the component layer, the wire layer, the signals panel, and the counts from the current model. It's what you call when the *structure* changed: a part was placed, deleted, rotated, rewired.
paint() is canvas-only and coalesced into a single requestAnimationFrame. It never rebuilds the DOM. It's for high-frequency feedback that changes nothing in the model, hover highlights, the rubber-band selection rectangle, the wire preview while you're drawing, the ghost of a part you're about to drop.
The rule: only a model change may call redraw(). Everything else calls paint(). Break that rule once, and pan-and-zoom is rebuilding the scene sixty times a second for no reason.
Pan and zoom, in fact, don't touch either. They move the SVG viewBox. The browser re-rasterizes the existing nodes; only the graph-paper grid re-tiles. Dragging a part is the same idea: I capture the dragged group's DOM nodes once on pointer-down and then, per frame, write a single transform: translate(...). No model mutation, no re-render, until you let go. On release, the travel snaps to the grid and *one* redraw() commits it. During a gesture I also strip the SVG drop-shadow filters (one class on the root), because filters are what actually cost you during a live translate.
A state change is not a structure change
Here's the insight that paid off the most.
When you click an input to toggle it from 0 to 1, the circuit's *structure* doesn't change. No part moved, no wire was added. What changes is *state*: some nets go high, some transistors start conducting, some probes flip. But the editor was calling redraw() on every toggle, faithfully rebuilding every component's SVG from scratch to show new colors.
On a 161-part circuit, under a 4x CPU throttle to imitate a mid-range phone, that full rebuild took about 133 milliseconds per click. That's a visible hitch on the exact interaction people do most: flip an input, watch the circuit respond.
It turned out I already had the tool to fix it. A separate light path, originally written so a running clock wouldn't rebuild the canvas on every tick, patches net-state *in place*: it swaps the state- class on each wire, retints the junction dots, toggles a conducting class on transistors, and re-renders only the handful of parts that draw a live value in their body (probes, the seven-segment display, and, once I added them, input switches). No DOM rebuild.
So the fix was one branch in the commit path: if every action in this batch is an input toggle, and the sim didn't error, run the light patch instead of the full redraw. Same 161-part circuit, same throttle: 51 milliseconds, a 2.6x speedup, and the ~80ms of pointless DOM churn simply gone. I verified it renders identically to a full rebuild by diffing every pin's color class against the simulator's net states across the whole circuit, 242 pins, zero mismatches.
The general lesson is boring and keeps being true: know whether an operation changed structure or only state, and never pay structure prices for state changes. It's the browser version of not re-laying-out when you only need to repaint.
Keeping the cables clean while you drag
Speed is half of it. The other half is that moving parts is where circuits *look* like they broke, even when they didn't.
Three things fixed the mid-drag mess. First, while you drag a part, any wire with one end following the move used to render as a straight line between its two endpoints, which means a slanted diagonal cable dragged across the canvas for the whole gesture, snapping orthogonal only on release. A diagonal in an orthogonal schematic reads as broken. The fix is cheap: draw the follow-wire as an L, one corner, with the bend placed so the fixed end leaves along the wire's original axis. It's one extra point in the path string per frame, and now the cable is tidy the entire time.
Second, straight cables come from aligned pins, and getting two parts pixel- aligned by hand is tedious. So dragging is *magnetic*: when a dragged pin lands within one grid cell of a stationary part's row or column, the drop snaps to exact alignment. One cell of pull, computed by the same pure function for both the live preview outline and the committed drop, so what the dashed landing box promises is exactly where the part lands. A drag that's clearly not aiming for alignment is left alone.
Third, connection has to be *unambiguous*. Two wires crossing and two wires joining look nearly identical unless you're deliberate about it. A real connection gets a filled "solder" dot sized about three times the wire's stroke width, so it reads as a blob, not a bump. A crossing gets nothing. And a wire end that connects to *nothing*, which happens the moment you delete a part and leave its wires behind, gets a small hollow ring, the visual opposite of the filled dot. That last one is pure topology: an endpoint with degree one, not sitting on any pin, and not lying on another wire's interior. Compute that set and you can both draw the rings and offer a one-click "remove the dangling wires" cleanup that only touches the genuinely loose ones.
Why the model never fights the view
None of this works if a move can silently corrupt the circuit, and the tempting bugs here are all about the view and the model disagreeing. The discipline that keeps them honest: the model is the single source of truth, mutated only through one applyAction function, and the view is a pure function of the model plus a little transient interaction state (what's hovered, what's mid-drag). The drag gesture is explicitly *not* a model mutation, it's a stack of DOM transforms that gets thrown away and replaced by one real move on release. If the release would corrupt connectivity, it's refused before it touches the model, and the transient transforms just evaporate on the next paint.
That separation is what lets the fast paths be safe. The toggle fast-path can skip the full rebuild precisely because it's provable that a toggle changed only state; the drag can glide with zero model writes because the model isn't involved until commit. Speed and correctness usually trade off. Here they came from the same decision: keep a hard line between what changed the circuit and what only changed the picture of it.
Try it
The editor is dependency-free TypeScript and SVG; the engine has no DOM references so a desktop wrap can reuse it later. If you want to feel the render loop, open digiwleea.wleeaf.dev, drop a couple hundred transistors, and drag a selection around, then flip an input on a big circuit and notice it doesn't hitch. Happy to go deeper on the render split or the alignment math in the comments.
This is from building digiwleea, a free browser lab where you build a CPU starting from single transistors. Go move things around and try to break a circuit.