<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: iLoveVideoEditor</title>
    <description>The latest articles on DEV Community by iLoveVideoEditor (@ilovevideoeditor).</description>
    <link>https://dev.to/ilovevideoeditor</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4029376%2F55ec6471-bcb1-4ed9-9337-aeda6f0aba1c.png</url>
      <title>DEV Community: iLoveVideoEditor</title>
      <link>https://dev.to/ilovevideoeditor</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ilovevideoeditor"/>
    <language>en</language>
    <item>
      <title>How We Cut Our Browser Video Renderer's Frame Time by 80%</title>
      <dc:creator>iLoveVideoEditor</dc:creator>
      <pubDate>Wed, 15 Jul 2026 01:22:58 +0000</pubDate>
      <link>https://dev.to/ilovevideoeditor/how-we-cut-our-browser-video-renderers-frame-time-by-80-3db1</link>
      <guid>https://dev.to/ilovevideoeditor/how-we-cut-our-browser-video-renderers-frame-time-by-80-3db1</guid>
      <description>&lt;h1&gt;
  
  
  How We Cut Our Browser Video Renderer's Frame Time by 80%
&lt;/h1&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The renderer in 60 seconds
&lt;/h2&gt;

&lt;p&gt;Our engine renders a video by drawing every frame onto a canvas, then feeding the bitmaps to a WebCodecs &lt;code&gt;VideoEncoder&lt;/code&gt; (H.264 in MP4). Layers are DOM elements — text, images, shapes with real CSS — so rasterizing a layer means calling &lt;code&gt;domToCanvas&lt;/code&gt; from &lt;a href="https://www.npmjs.com/package/modern-screenshot" rel="noopener noreferrer"&gt;modern-screenshot&lt;/a&gt;, which serializes the element's subtree into an SVG &lt;code&gt;&amp;lt;foreignObject&amp;gt;&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we measured
&lt;/h2&gt;

&lt;p&gt;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 &lt;strong&gt;median per scene&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: preview overlays billed to export
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;EffectOverlayManager&lt;/code&gt; — and its &lt;code&gt;process()&lt;/code&gt; ran &lt;strong&gt;on every exported frame too&lt;/strong&gt;, even though export has no UI at all. Cost: 60–260ms per frame of pure waste, depending on the scene.&lt;/p&gt;

&lt;p&gt;The fix was a flag: &lt;code&gt;captureFrame&lt;/code&gt; (the export path) now gates overlay processing off. Free win, zero risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: WebGL context churn
&lt;/h2&gt;

&lt;p&gt;Effect layers composite at their padded bounding-box size. The old code did this whenever the size changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;effectCompositor&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;destroy&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;effectCompositor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebGLEffectCompositor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;padded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;padded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Destroying a compositor means losing the GL context (&lt;code&gt;WEBGL_lose_context&lt;/code&gt;) 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;acquire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;renderWidth&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;renderHeight&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// hot path: keep what you have&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// check old one back in&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;x&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebGLEffectCompositor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Finding 3: the DOM capture that ran 1,050 times
&lt;/h2&gt;

&lt;p&gt;This was the big one. &lt;code&gt;domToCanvas&lt;/code&gt; costs 16–300ms per call — it's a full DOM serialization and rasterization. We already cached layer surfaces, but the cache key included &lt;strong&gt;every&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;The insight that fixes it: &lt;strong&gt;content and transform are different things.&lt;/strong&gt; 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 &lt;code&gt;drawImage&lt;/code&gt; with an affine transform and &lt;code&gt;globalAlpha&lt;/code&gt; do, in microseconds.&lt;/p&gt;

&lt;p&gt;So the cache is now two-level:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Level A (full-props key)&lt;/strong&gt; — every prop participates. A hit returns the finished surface untouched. Same as before.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Level B (content key)&lt;/strong&gt; — 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 &lt;code&gt;fade&lt;/code&gt; animate it every frame alongside opacity). A hit skips &lt;code&gt;domToCanvas&lt;/code&gt; entirely and redraws the cached content bitmap with the current transform: ~1–3ms instead of 16–300ms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Level B is only correct because of how the capture is built: the clone is rasterized with &lt;code&gt;transform: none&lt;/code&gt; and &lt;code&gt;opacity: 1&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;One more detail in the same vein: empty placeholder layers (zero-size) used to go through the &lt;code&gt;foreignObject&lt;/code&gt; round-trip every frame too. Caching the zero-size result costs one map entry and skips that entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 4: the opacity² bug
&lt;/h2&gt;

&lt;p&gt;We verify performance changes with pixel diffs — render the same frames before and after, compare bitmaps. The diffs came back identical &lt;strong&gt;except&lt;/strong&gt; for fades, and the new output was &lt;em&gt;less&lt;/em&gt; transparent than the old. That wasn't a regression: it exposed an existing bug. Export had been baking opacity into the captured bitmap &lt;strong&gt;and&lt;/strong&gt; applying it again via &lt;code&gt;globalAlpha&lt;/code&gt; — effectively &lt;code&gt;opacity²&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;Scene medians per frame on the product-launch template that started all this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scene&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scene 1 (hero product + effects)&lt;/td&gt;
&lt;td&gt;1024ms&lt;/td&gt;
&lt;td&gt;165ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scene 2 (feature callouts)&lt;/td&gt;
&lt;td&gt;716ms&lt;/td&gt;
&lt;td&gt;141ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scene 3 (image gallery)&lt;/td&gt;
&lt;td&gt;518ms&lt;/td&gt;
&lt;td&gt;98ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scene 4 (CTA)&lt;/td&gt;
&lt;td&gt;543ms&lt;/td&gt;
&lt;td&gt;87ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;End to end, that's roughly an &lt;strong&gt;80% cut&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still slow
&lt;/h2&gt;

&lt;p&gt;Honesty section, because the server number (3 minutes for 30 seconds of video) is still far from the browser number:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SwiftShader.&lt;/strong&gt; 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).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Level-B trade-offs.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encode is now the floor.&lt;/strong&gt; With rendering this cheap, the WebCodecs encoder is a visible share of per-frame time. That's a good problem to have.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure per stage, per frame, per scene.&lt;/strong&gt; "Render is slow" was unactionable; four medians were four fixes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate content from transform.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preview and export are different workloads.&lt;/strong&gt; Don't bill preview-only work to the export path; it compounds per frame.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebGL context creation is a budget item.&lt;/strong&gt; Pool contexts the way you pool connections.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pixel-diff your "no visual change" claims.&lt;/strong&gt; It's cheap, and it caught a correctness bug we didn't know we had.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're building something similar — a canvas renderer, a DOM-to-image pipeline, a headless export service — the &lt;a href="https://dev.to/docs/api"&gt;API docs&lt;/a&gt; show the render endpoint this engine sits behind, and the &lt;a href="https://dev.to/install"&gt;CLI&lt;/a&gt; (&lt;code&gt;npm i -g ilovevideoeditor&lt;/code&gt;) can render a template locally so you can watch the frame counter move. It moves a lot faster now.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>webcodecs</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
