When we set out to build the SimuPro cardiac monitor, we had a non-negotiable: it must look and feel like the real thing on a Zoll, a LIFEPAK, or a Philips MRx — and it must do that in a browser tab, on a Chromebook, in a station crew room, mid-shift. Here's how we got there, where we burned weekends, and what we'd do differently.
The starting problem
A live ECG strip looks deceptively simple. Three boxes wide, twenty-five millimeters per second, a sawtooth grid, and a glowing trace that wraps every ~6 seconds. Easy, right? Three weekends in, we had: a janky sweep that dropped frames on every state update, a clipPath that visibly clipped at the wrong subpixel on Firefox, and a capnograph that lagged the ECG by ~80ms on slower machines.
We had a choice: <canvas> with manual draw calls, or <svg>with strategic mutation. We picked SVG, and that's most of what this post is about.
Why not canvas?
The instinct is canvas — it's designed for high-FPS pixel work. But for a clinical monitor specifically, three properties of SVG turned out to matter more than raw throughput.
Hit-testing for free
Learners can tap any waveform to bring up its alarm config. With canvas, that's a manual implementation: track every drawn segment, intersect with pointer events. With SVG, you get it as DOM pointer-events. We never wrote a hit-test routine. One weekend saved, instantly.
Subpixel crispness
An ECG trace at 1px stroke width is a brutal rendering target. Browsers gamma-correct strokes inconsistently between canvas and SVG. SVG, despite its quirks, gives us repeatable subpixel positioning when we use shape-rendering="geometricPrecision" and round our path commands.
The rAF sweep loop
Our sweep is one requestAnimationFrameloop per channel. Inside the loop, we don't re-render React — we mutate the x attribute of an SVG <g> directly. React knows nothing about the sweep, which means our component tree stays still.
function sweep(ts: number) {
const dt = ts - lastTs;
lastTs = ts;
const pxPerMs = sweepSpeedPx / 1000;
cursorRef.current += dt * pxPerMs;
// mutate, don't setState
groupRef.current!.setAttribute(
'transform',
`translate(${-cursorRef.current % tileWidth}, 0)`,
);
rafIdRef.current = requestAnimationFrame(sweep);
}clipPath caveats
The strip wraps every ~6 seconds. That wrap is a <clipPath> — the trace renders into an infinitely long path, but only the visible window draws. Easy in theory; in practice, the clip-region cached at definition time on some browsers.
Workaround: never animate the clipPath. Instead, give it a fixed viewport, and translate the contents underneath it.
“Animate the contents, not the window.” — eight words that saved us a week.
The Safari bug that ate three weekends
Safari was rendering our ECG paths at the wrong subpixel offset after a tab returned from background. Couldn't reproduce in Chrome. Couldn't reproduce in Firefox.
Root cause: Safari was caching transform matrices on offscreen elements differently. The fix was to set the transform on a wrapper that was never offscreen.
Takeaways
- For interactive, clinical-feeling SVG strips: stay in SVG, mutate the DOM directly, keep React out of the inner loop.
- Use canvas for fills, gradients, and decorative work where you don't need event handling.
- Never animate a clipPath — animate the contents underneath it.
- Profile against production. Always.
- Test sleep/wake cycles on every browser you ship to. Especially Safari.
The cardiac monitor is the centerpiece of the SimuPro cockpit. If you want to put hands on it, the free tier includes the full ECG monitor in every scenario — no card required.