If you're building anything that records a with MediaRecorder — a video editor, a
whiteboard exporter, a game clip recorder — there's a bug you will eventually ship without
realizing it. It doesn't show up in testing, because you're staring at the tab while you test. It
only shows up for real users, because real users tab away while they wait.
Here's what happened, how I found it, and how I fixed it.
The bug report
A tester using a browser-based video editor I work on (multi-track timeline, text/shape
overlays, export to WebM) reported something strange:
I created a video with 5+ slides. After exporting, the file only shows one slide
instead of all of them. Reproducible in VLC, Windows Media Player, and Films &
TV.
"Reproducible across every player" was the key clue. If it were a codec or container problem,
different players might handle it differently. Here, every player agreed: the file genuinely only
contained one frame's worth of content, repeated for the full duration.
How the export actually worked
Like most browser-based editors without a server-side rendering pipeline, export worked by
literally playing the project in real time and recording the canvas as it played:
const videoStream = canvas.captureStream(fps) // auto-samples the canvas at fps
const recorder = new MediaRecorder(combinedStream, { mimeType: 'video/webm;codecs=v
recorder.start(100)
setPlayhead(0)
setPlaying(true) // kicks off the normal requestAnimationFrame preview loop
The preview loop is a standard rAF ticker:
function tick() {
const now = performance.now()
const delta = (now - lastRef.current) / 1000
lastRef.current = now
setPlayhead(prev => {
const next = prev + delta
renderFrame(next) // draws clips/text/shapes onto the canvas
return next
})
requestAnimationFrame(tick)
}
This works perfectly in every manual test, because manual testing means staring at the
export progress bar.
The actual cause
A 20-slide, 40-second export takes roughly 40 real seconds to record, because it's literally
real-time playback. Nobody stares at a progress bar for 40 seconds — they switch tabs,
check Slack, glance at something else, and switch back when it's done.
requestAnimationFrame is throttled or fully suspended by the browser the moment a tab is
backgrounded. It's a deliberate, documented browser optimization for battery/CPU — not a
bug in the browser.
So the sequence was:
- User clicks Export.
- Recording starts, playback starts via the rAF loop.
- User switches tabs after ~3 seconds (while slide 1 is on screen).
- The tab backgrounds → requestAnimationFrame stops firing → the canvas stops being redrawn → it stays frozen on slide 1.
- canvas.captureStream(fps) doesn't know or care that the canvas stopped changing. It keeps sampling at the configured fps regardless — it just samples the same frozen pixels, over and over, for the rest of the export.
- MediaRecorder faithfully records exactly what it's given: 40 seconds of slide 1.
The exported file's duration was correct. Its content was a single frame, repeated. That's why
every player agreed on what was wrong — there was nothing ambiguous to disagree about.
The fix: stop relying on rAF for export
The preview loop can stay rAF-based — that's genuinely the right tool when a human is
watching and the tab is guaranteed foregrounded. Export needs its own loop, driven by
something that isn't gated on tab visibility the same way.
setInterval is still throttled in background tabs (browsers clamp it, typically to once per
second after a few minutes hidden), but critically, it keeps firing — it doesn't fully suspend
the way rAF does. And since each tick computes elapsed time from a real wall-clock delta
rather than assuming a fixed frame interval, a throttled tick still renders the correct frame for
that moment — it just produces fewer distinct frames during the backgrounded stretch
instead of freezing entirely.
Combined with manual frame capture ( captureStream(0) + track.requestFrame() instead
of letting the browser auto-sample), the export loop became fully self-contained:
const videoTrack = canvas.captureStream(0).getVideoTracks()[0] // 0 = manual mode
let last = performance.now()
let time = 0
const stepExport = () => {
const now = performance.now()
time = Math.min(totalDuration, time + (now - last) / 1000)
last = now
renderFrame(time) // draw the correct frame for
timevideoTrack.requestFrame() // push exactly this frame into the recording if (time >= totalDuration) { clearInterval(intervalId) recorder.stop() } } const intervalId = setInterval(stepExport, 1000 / fps) No more dependency on the preview's rAF loop, no more silent freeze when a tab loses focus. Verifying it, not just believing it Code review alone doesn't prove a timing bug is fixed — you have to reproduce the actual failure mode. I built a small 4-slide test project (distinct solid colors, 5 seconds each), triggered export, and then deliberately backgrounded the tab for the entire 20-second export by opening and focusing a second blank tab — the exact thing the original tester must have done. Then I pulled frames from the output at several timestamps and sampled pixel color: Time Color shown Expected 1–2s Green Green (0–5s) 7s Blue Blue (5–10s) 12s Yellow Yellow (10–15s) 17–19s Purple Purple (15–20s) All four slides, in the correct sequence, despite the tab being backgrounded the whole time. That's the actual test that matters — not "does it compile," but "does it survive the exact realworld behavior that broke it." The takeaway If your app does anything time-based while backgrounded — canvas recording, animations syncing to real time, polling that assumes regular intervals — don't assume requestAnimationFrame is a safe default just because it's the "correct" tool for rendering. It's the correct tool for rendering to a user who's looking at the screen. The moment you need something to keep working while nobody's looking, you need a loop that doesn't know or care whether the tab is visible. I found and fixed this while working on ToolHub's Video Editor Pro — a free, browser-based video editor with multi-track timeline editing, transitions, keyframe animation and WebM export, no signup required
Top comments (0)