How We Cut Our Browser Video Renderer's Frame Time by 80%
A 30-second product promo — 1,050 frames at 1920×1080 — was rendering at roughly one frame per second. In the browser, on a machine that plays modern games without blinking. The same job on our headless render server took 10 minutes.
This is the writeup of how we found four distinct problems, fixed them in a single commit, and got scene medians down from ~0.5–1.0s to ~90–165ms per frame — with pixel-level proof that the output didn't change. Well, almost: the verification pass also caught a real bug.
The renderer in 60 seconds
Our engine renders a video by drawing every frame onto a canvas, then feeding the bitmaps to a WebCodecs VideoEncoder (H.264 in MP4). Layers are DOM elements — text, images, shapes with real CSS — so rasterizing a layer means calling domToCanvas from modern-screenshot, which serializes the element's subtree into an SVG <foreignObject> and rasterizes it. Effect layers additionally go through a WebGL compositor for blur, distortion, and CSS-filter passes. The exact same bundle runs in two places: your browser tab for preview and local export, and headless Chromium on our Railway workers for API renders.
That architecture is why "one frame per second" was so embarrassing: every layer already renders as a flat bitmap. The work per frame should be a handful of canvas draws and one encode call.
How we measured
We didn't profile the whole render — that number is a sum, and sums hide everything. We put timers around each stage of the per-frame pipeline (DOM capture, effect compositing, overlay processing, encode) and took the median per scene, because our four scenes use very different layers. The overall median was ~0.9 seconds of serial work per frame. The breakdown pointed at four separate problems, in four separate subsystems.
Finding 1: preview overlays billed to export
The preview UI shows post-effect overlay canvases so you can see what an effect does to a layer in isolation. That work is owned by an EffectOverlayManager — and its process() ran on every exported frame too, even though export has no UI at all. Cost: 60–260ms per frame of pure waste, depending on the scene.
The fix was a flag: captureFrame (the export path) now gates overlay processing off. Free win, zero risk.
Finding 2: WebGL context churn
Effect layers composite at their padded bounding-box size. The old code did this whenever the size changed:
effectCompositor?.destroy();
effectCompositor = new WebGLEffectCompositor(padded.width, padded.height);
Destroying a compositor means losing the GL context (WEBGL_lose_context) and recompiling every shader on the next frame. Animated layers change their bbox constantly, so we were paying a full context teardown mid-render, over and over. The fix is a tiny LRU pool keyed by exact render size, capped at four instances — enough to cover the few sizes a scene alternates between, small enough that an animated layer can't grow it unbounded:
acquire(width, height, current) {
if (current?.renderWidth === width && current?.renderHeight === height) {
return current; // hot path: keep what you have
}
if (current) this.store(current); // check old one back in
return this.idle.get(`${width}x${height}`) ?? new WebGLEffectCompositor(width, height);
}
Finding 3: the DOM capture that ran 1,050 times
This was the big one. domToCanvas costs 16–300ms per call — it's a full DOM serialization and rasterization. We already cached layer surfaces, but the cache key included every prop. So a simple opacity fade — the most common animation in any template — invalidated the capture on every single frame, and we re-rasterized identical content 1,050 times per render.
The insight that fixes it: content and transform are different things. A fade or a slide changes where and how transparently a layer is drawn; it doesn't change what the layer looks like. And canvas already knows how to translate, scale, rotate, and set alpha — that's what drawImage with an affine transform and globalAlpha do, in microseconds.
So the cache is now two-level:
- Level A (full-props key) — every prop participates. A hit returns the finished surface untouched. Same as before.
-
Level B (content key) — excludes the props that are applied only at draw time: transform, opacity, CSS filters (consumed downstream by the WebGL pass), and audio volume (transitions like
fadeanimate it every frame alongside opacity). A hit skipsdomToCanvasentirely and redraws the cached content bitmap with the current transform: ~1–3ms instead of 16–300ms.
Level B is only correct because of how the capture is built: the clone is rasterized with transform: none and opacity: 1 forced, so the bitmap is transform-free and opacity-free by construction. Whatever the animation does is re-applied at draw time, and the result is pixel-identical to re-capturing.
One more detail in the same vein: empty placeholder layers (zero-size) used to go through the foreignObject round-trip every frame too. Caching the zero-size result costs one map entry and skips that entirely.
Finding 4: the opacity² bug
We verify performance changes with pixel diffs — render the same frames before and after, compare bitmaps. The diffs came back identical except for fades, and the new output was less transparent than the old. That wasn't a regression: it exposed an existing bug. Export had been baking opacity into the captured bitmap and applying it again via globalAlpha — effectively opacity². Preview applied it once. Export and preview had disagreed about every fade in every video, and nobody had noticed because a slightly-more-opaque fade still looks like a fade.
The fix: strip opacity from the capture clone (already done for Level B), apply it exactly once at draw time. Export now matches preview — which is the actual specification.
Results
Scene medians per frame on the product-launch template that started all this:
| Scene | Before | After |
|---|---|---|
| Scene 1 (hero product + effects) | 1024ms | 165ms |
| Scene 2 (feature callouts) | 716ms | 141ms |
| Scene 3 (image gallery) | 518ms | 98ms |
| Scene 4 (CTA) | 543ms | 87ms |
End to end, that's roughly an 80% cut. The same 30-second video that took ~10 minutes on the render server now finishes in about 3. Local export in a real browser went from "one frame per second" to comfortably faster than realtime.
What's still slow
Honesty section, because the server number (3 minutes for 30 seconds of video) is still far from the browser number:
- SwiftShader. Headless Chromium on a server has no GPU, so every WebGL pass runs on software rasterization. That's the dominant remaining cost on API renders, and no amount of caching changes it — the fix list there is different (smaller effect surfaces, GPU instances, or skipping GL for passes the canvas API can do).
- Level-B trade-offs. Redrawing a cached bitmap is pixel-identical for translate/fade, but a layer scaled up 3× is still drawn from a 1× capture — canvas upscaling is good, not magic. For extreme scale animations we'd want to re-capture at the target density; we don't do that yet.
- Encode is now the floor. With rendering this cheap, the WebCodecs encoder is a visible share of per-frame time. That's a good problem to have.
Takeaways
- Measure per stage, per frame, per scene. "Render is slow" was unactionable; four medians were four fixes.
- Separate content from transform. Cache the expensive thing (a rasterized bitmap), re-apply the cheap thing (affine + alpha). This pattern shows up everywhere — game engines, browsers, and your React app all do some version of it.
- Preview and export are different workloads. Don't bill preview-only work to the export path; it compounds per frame.
- WebGL context creation is a budget item. Pool contexts the way you pool connections.
- Pixel-diff your "no visual change" claims. It's cheap, and it caught a correctness bug we didn't know we had.
If you're building something similar — a canvas renderer, a DOM-to-image pipeline, a headless export service — the API docs show the render endpoint this engine sits behind, and the CLI (npm i -g ilovevideoeditor) can render a template locally so you can watch the frame counter move. It moves a lot faster now.
Top comments (0)