Double Buffering Video Switches: No More Black Frames Between Clips
In carousels, product showcases, or background animations, when switching videos you might encounter this: the moment you click to switch, the screen blacks out for a moment, or it freezes in place, and after a while the new video appears. The video itself is fine; the problem lies in the "switch" action.
This article uses a set of comparison demos between a single video and dual videos to explain how this gap arises, and introduces an optimization method that only needs a dozen or so lines of code—double buffering.
Why switching videos "flashes"
Carousels, product showcases, and background animations often need to switch different videos in the same position. The most straightforward way is to obtain the video element and directly change its src:
video.removeAttribute('src')
video.load()
video.src = nextSrc
video.play()
The problem is that this video simultaneously handles two things: displaying the current frame and loading the next resource. Once it starts loading the new resource, the old frame becomes unavailable, and the new video has to go through downloading and decoding before its first frame appears. During that time, the user sees a black screen, a background color, or a "loading" overlay.
This gap—"the old frame is gone, the new frame hasn't arrived yet"—is the source of the stutter during switching.
Look at the single video approach first
Below is the single video implementation on the left side of the demo. To make the gap easier to observe, we show a "loading" overlay during the switch and remove it after the playing event fires:
<template>
<div class="stage">
<video ref="video" muted playsinline loop></video>
<div v-if="loading" class="mask">Loading…</div>
</div>
</template>
<script setup lang="ts">
const video = useTemplateRef<HTMLVideoElement>('video')
const loading = ref(false)
const play = (src: string) => {
const el = video.value!
el.src = src
el.play().catch(() => {})
}
const switchClip = (index: number) => {
loading.value = true
// Directly clear the currently displayed video, the old frame immediately disappears
video.value!.removeAttribute('src')
video.value!.load()
play(clips[index].src)
}
onMounted(() => {
video.value!.addEventListener('playing', () => (loading.value = false))
play(clips[0].src)
})
</script>
Here the overlay just "visualizes" the gap. Even if you remove the overlay, the user still sees the background show through or the frame stuck.
Dual video: separate loading from displaying
The idea of double buffering is simple: since one video cannot both "display" and "load" at the same time, use two.
- One
videostays in the foreground, continuing to display the current frame. - Another
videohides in the background, specifically loading the new video. - After the background video's first frame is ready, then swap their display states.
Click switch → foreground stays still → background loads new video → first frame ready → play background, pause foreground → swap display
Throughout the entire process, the user always sees a video with an image; there is no gap.
Step 1: Stack two videos
Stack two video elements in the same container, and use a boolean to record which one is currently displayed.
<template>
<div class="stage">
<video ref="videoA" :class="{ show: isAVisible }" muted playsinline loop></video>
<video ref="videoB" :class="{ show: !isAVisible }" muted playsinline loop></video>
</div>
</template>
Styling: both videos are absolutely positioned with identical dimensions, and the one without the show class is hidden using visibility: hidden:
.stage {
position: relative;
aspect-ratio: 16 / 9;
background: #000;
}
.stage video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.stage video:not(.show) {
visibility: hidden;
}
Using visibility instead of v-if here ensures the two video elements always exist and won't lose state due to destroy-and-recreate. muted and playsinline help satisfy the conditions for muted inline autoplay on mobile.
Step 2: Only load in the background, swap after first frame ready
The core logic is only a dozen or so lines:
const videoA = useTemplateRef<HTMLVideoElement>('videoA')
const videoB = useTemplateRef<HTMLVideoElement>('videoB')
// Whether A or B is currently displayed
const isAVisible = ref(true)
const switchClip = (clipIndex: number) => {
const visibleVideo = isAVisible.value ? videoA.value! : videoB.value! // The one being displayed
const standbyVideo = isAVisible.value ? videoB.value! : videoA.value! // The hidden background
// Only load in the background, keep the currently displayed one unchanged
standbyVideo.src = clips[clipIndex].src
// After the background's first frame is ready: play it, pause the currently displayed one, swap display
standbyVideo.onloadeddata = () => {
standbyVideo.play().catch(() => {})
visibleVideo.pause()
isAVisible.value = !isAVisible.value
}
}
onMounted(() => {
videoA.value!.src = clips[0].src
videoA.value!.play().catch(() => {})
})
Key points:
visibleVideo/standbyVideoare recalculated on each click. They are relative roles, determined byisAVisible.- The object whose
srcis set is always the background video; the foreground video is completely unaffected, and the picture never interrupts. - Use
loadeddataas the swap timing. It indicates that the current playback position already has renderable data, meaning the first frame is ready. Compared toloadedmetadata(which only gets dimensions and duration, not necessarily a picture), it is more suitable as the "can display now" signal. - The swap is just flipping a boolean. After
isAVisiblechanges, bothvideoelements'showclasses switch at the same time—one shows, one hides—within the same render. - Reassign
onloadeddataon each click. With rapid consecutive clicks, the later assignment overwrites the previous callback, so only the last selection ultimately takes effect.
Advantages of dual video
Comparing single video and dual video side by side, the benefits of double buffering mainly include the following.
1. The picture is not interrupted during switching
This is the most direct benefit. Single video switching inevitably goes through the gap of "old frame disappears → new frame appears", while with dual video the foreground always keeps the current frame until the new video's first frame is ready, and then swaps; the user doesn't see a black screen or overlay.
2. The old video can continue playing during the wait
With single video, the old video is cleared immediately upon switching. With dual video, while the background is loading, the foreground video continues playing normally, and only pauses the moment the new video is ready. The wait is nearly imperceptible to the user.
3. A slow load won't leave an empty frame
With a slow network, a new video may take several seconds before its first frame. With single video, the frame is empty during those seconds; with dual video, the old content continues to display, so the experience is "a little delayed" rather than "broken".
4. Low implementation cost
The entire solution only uses Vue's reactivity and the browser's native video element; no player library, no Canvas composition. The core logic is less than twenty lines, easy to understand and maintain.
5. Leaves room for extension
On the skeleton of "load in background, swap when ready", it's natural to extend further:
- Replace
visibilitywithopacityplus a transition to do fade in/out. - Add an
errorevent to the background video to keep the old frame and notify the user on failure. - Preload the next video when the user hovers over a button to further shorten perceived latency.
What it cannot solve
Double buffering optimizes "the experience at the moment of switching", but its boundaries should also be known:
- It will not reduce video size or speed up the network. The new video still takes as long to download; this waiting time just no longer appears as an empty frame.
- The first load still requires waiting. On the first entry to the page, there is no old frame to retain, so something like a poster image is needed as a fallback.
- It occupies one extra
videoresource. Two video elements exist simultaneously; on mobile, the decoding overhead of high-resolution videos should be noted. It's enough to keep only "current" and "next". - The demo code is a simplified version. It doesn't handle load failures, doesn't wait for
play()to succeed before swapping, and doesn't release resources from the old video after the swap. For production use, it's recommended to adderrorhandling and clear the hidden layer'ssrcafter the swap completes. - It doesn't solve composition differences. If two videos have very different content and composition, even without a gap in the transition, there will still be a visible visual jump.
Demo
The demo page is currently under maintenance, will be shown later...
Summary
The stutter when switching videos essentially comes from "one element having to both display the old frame and load the new resource". Double buffering splits these two tasks across two video elements: the foreground handles display, the background handles loading, and they swap after the first frame is ready. It doesn't change the load speed of the resource itself, but it stops that wait from appearing as an empty frame, trading a very small amount of code for a noticeably smoother switching experience.
Top 2 from juejin.cn, machine-translated. The original thread is authoritative.
Yes.
Keeping the old frame when loading fails is very practical.