A technical deep-dive into making live video streams work with single-page application transitions
The Problem
Ever tried combining smooth page transitions with live video streaming? If you have, you’ve probably run into the same headache I did: video players breaking after Barba.js transitions.
The issue is straightforward but frustrating. Barba.js creates smooth page transitions by replacing DOM content, but when it does that, your carefully initialized video players get destroyed. The video element is still there in the HTML, but the player instance? Gone.
Here’s what we were working with—a simple Cloudflare Stream video element:
<div class="cf-stream">
<video id="cfStream_462277e5b989d7d092eb129144809be4"
width="800"
height="450"
autoplay
muted
playsinline>
</video>
</div>
After a Barba transition, the video element would be re-rendered in the DOM, but the HLS.js player instance that was managing playback? Completely destroyed. No video, no recovery, just a dead player.
The Existing Solution (Almost There)
The initial code was actually pretty solid—a self-contained plugin that handled HLS streaming with error recovery and a watchdog timer to detect stalled streams:
(function () {
function initPlayer(videoId, src) {
const VIDEO = document.getElementById(videoId);
if (!VIDEO) return;
let hls;
// HLS initialization logic
if (VIDEO.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS support (Safari)
VIDEO.src = src;
VIDEO.addEventListener('loadedmetadata', () => {
VIDEO.play().catch(() => {});
}, { once: true });
} else if (window.Hls && Hls.isSupported()) {
// HLS.js for other browsers
hls = new Hls({ lowLatencyMode: true });
hls.attachMedia(VIDEO);
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
hls.loadSource(src);
hls.startLoad();
});
// Error handling
hls.on(Hls.Events.ERROR, (_, data) => {
if (!data.fatal) return;
// Recovery logic...
});
}
// Watchdog timer to detect stalls...
}
document.addEventListener('DOMContentLoaded', () => {
if (!window.CFStreamSources) return;
for (const id in window.CFStreamSources) {
initPlayer(id, window.CFStreamSources[id]);
}
});
})();
The problem? This only ran once on page load. After a Barba.js transition, the video elements were fresh but uninitialized.
The video would just sit there. Black screen. No playback. No errors—just nothing.
Why Refactor to Classes?
Instead of just exposing the function globally (which would work), we decided to refactor into a class-based structure. Why?
- Memory management – Classes let us properly clean up HLS instances before page transitions
- State tracking – Each player instance maintains its own state (watchdog timer, stall counter, HLS instance)
- Scalability – Easy to manage multiple video players on a page
- Reusability – Clean API for reinitializing after transitions
The goal was to create a system that could:
- Initialize players when the page loads
- Clean up completely before page transitions
- Reinitialize cleanly after new content loads
- Handle multiple players on a single page
- Prevent memory leaks
The Build Process
Step 1: Create the Player Class
First, we extracted the player logic into a CFStreamPlayer
class:
class CFStreamPlayer {
constructor(videoId, src) {
this.videoId = videoId;
this.src = src;
this.video = document.getElementById(videoId);
this.hls = null;
this.lastTime = 0;
this.stalled = 0;
this.watchdogInterval = null;
if (this.video) {
this.init();
}
}
init() {
this.create(this.src);
this.startWatchdog();
}
create(url) {
// Clean up existing instance
if (this.hls) this.hls.destroy();
// Native HLS support (Safari)
if (this.video.canPlayType('application/vnd.apple.mpegurl')) {
this.video.src = url;
this.video.addEventListener(
'loadedmetadata',
() => this.video.play().catch(() => {}),
{ once: true }
);
}
// HLS.js for other browsers
else if (window.Hls && Hls.isSupported()) {
this.hls = new Hls({ lowLatencyMode: true });
this.hls.attachMedia(this.video);
this.hls.on(Hls.Events.MEDIA_ATTACHED, () => {
this.hls.loadSource(url);
this.hls.startLoad();
});
this.hls.on(Hls.Events.ERROR, (_, data) => {
if (!data.fatal) return;
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
this.hls.startLoad();
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
this.hls.recoverMediaError();
} else {
this.reinit();
}
});
}
}
reinit() {
// Add cache-busting timestamp
const busted = this.src +
(this.src.includes('?') ? '&' : '?') +
'ts=' + Date.now();
this.create(busted);
}
startWatchdog() {
if (this.watchdogInterval) return;
this.watchdogInterval = setInterval(() => {
if (this.video.paused || this.video.ended) return;
// Check if video time hasn't progressed
if (Math.abs(this.video.currentTime - this.lastTime) < 0.25) {
if (this.stalled === 0 && this.hls) {
try { this.hls.recoverMediaError(); } catch (e) {}
} else if (this.stalled === 1 && this.hls) {
try { this.hls.startLoad(); } catch (e) {}
} else {
this.reinit();
}
this.stalled++;
} else {
this.stalled = 0;
}
this.lastTime = this.video.currentTime;
}, 4000);
}
destroy() {
if (this.hls) {
this.hls.destroy();
this.hls = null;
}
if (this.watchdogInterval) {
clearInterval(this.watchdogInterval);
this.watchdogInterval = null;
}
if (this.video) {
this.video.pause();
this.video.src = '';
}
}
}
The key addition here is the destroy()
method—critical for cleaning up before Barba transitions.
This method:
- Destroys the HLS.js instance
- Clears the watchdog interval
- Stops and clears the video element
Without proper cleanup, you get memory leaks. With every page transition, a new HLS instance would be created while the old one lingered in memory, eventually degrading performance.
Step 2: Create the Manager Class
Then we built a manager to handle multiple players:
class CFStreamManager {
constructor() {
this.players = new Map();
}
initAll() {
if (!window.CFStreamSources) {
console.warn('window.CFStreamSources not found');
return;
}
// Clean up existing players first
this.destroyAll();
// Initialize new players
for (const id in window.CFStreamSources) {
const player = new CFStreamPlayer(id, window.CFStreamSources[id]);
this.players.set(id, player);
}
}
destroyAll() {
this.players.forEach(player => player.destroy());
this.players.clear();
}
}
Simple and clean—track all players in a Map, destroy them all, reinitialize them all.
The Map data structure is perfect here because:
- We can look up players by video ID
- We can iterate over all players easily
- We can clear everything in one call
Step 3: The Debugging Phase (This Is Where It Got Fun)
After writing the classes, we ran into a classic JavaScript gotcha:
window.cfStreamManager.initAll();
// TypeError: Cannot read properties of undefined (reading 'initAll')
Wait, what?
Lesson #1: Define the class, but don’t forget to instantiate it!
// This defines the class:
class CFStreamManager { }
// This creates an instance:
window.cfStreamManager = new CFStreamManager();
I had written the class definition but never actually created an instance. The class exists, but there’s no object to call methods on.
Fixed that, then hit another error:
window.cfStreamManager.initAll();
// ReferenceError: CFStreamPlayer is not defined
Lesson #2: Both classes need to be in scope!
The manager calls new CFStreamPlayer()
, so both classes must be defined before creating the manager instance.
The correct order:
// 1. Define CFStreamPlayer class
class CFStreamPlayer { /* ... */ }
// 2. Define CFStreamManager class (which references CFStreamPlayer)
class CFStreamManager { /* ... */ }
// 3. Create the manager instance
window.cfStreamManager = new CFStreamManager();
This is a scope issue that’s easy to miss. If you define CFStreamManager
before CFStreamPlayer
, or if you instantiate the manager before defining the classes, you’ll get reference errors.
Step 4: Wire It Up with Barba.js
The final piece—make sure players reinitialize after every page transition:
barba.init({
transitions: [{
leave() {
// Clean up before transition
if (window.cfStreamManager) {
window.cfStreamManager.destroyAll();
}
},
afterEnter() {
// Reinitialize after new content loads
if (window.cfStreamManager) {
window.cfStreamManager.initAll();
}
}
}]
});
Why the if
checks?
They prevent errors if:
- The script hasn’t loaded yet
- The page doesn’t have any video players
- There’s a race condition during initialization
Defensive coding saves debugging time.
The lifecycle:
- User clicks a link
- Barba’s
leave
hook fires → destroy all players - Barba fetches new content
- Barba swaps the DOM
- Barba’s
afterEnter
hook fires → reinitialize all players - Videos start playing
The Complete Solution
Here’s the final, working code:
class CFStreamPlayer {
constructor(videoId, src) {
this.videoId = videoId;
this.src = src;
this.video = document.getElementById(videoId);
this.hls = null;
this.lastTime = 0;
this.stalled = 0;
this.watchdogInterval = null;
if (this.video) {
this.init();
}
}
init() {
this.create(this.src);
this.startWatchdog();
}
create(url) {
if (this.hls) this.hls.destroy();
if (this.video.canPlayType('application/vnd.apple.mpegurl')) {
this.video.src = url;
this.video.addEventListener(
'loadedmetadata',
() => this.video.play().catch(() => {}),
{ once: true }
);
} else if (window.Hls && Hls.isSupported()) {
this.hls = new Hls({ lowLatencyMode: true });
this.hls.attachMedia(this.video);
this.hls.on(Hls.Events.MEDIA_ATTACHED, () => {
this.hls.loadSource(url);
this.hls.startLoad();
});
this.hls.on(Hls.Events.ERROR, (_, data) => {
if (!data.fatal) return;
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
this.hls.startLoad();
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
this.hls.recoverMediaError();
} else {
this.reinit();
}
});
}
}
reinit() {
const busted = this.src +
(this.src.includes('?') ? '&' : '?') +
'ts=' + Date.now();
this.create(busted);
}
startWatchdog() {
if (this.watchdogInterval) return;
this.watchdogInterval = setInterval(() => {
if (this.video.paused || this.video.ended) return;
if (Math.abs(this.video.currentTime - this.lastTime) < 0.25) {
if (this.stalled === 0 && this.hls) {
try { this.hls.recoverMediaError(); } catch (e) {}
} else if (this.stalled === 1 && this.hls) {
try { this.hls.startLoad(); } catch (e) {}
} else {
this.reinit();
}
this.stalled++;
} else {
this.stalled = 0;
}
this.lastTime = this.video.currentTime;
}, 4000);
}
destroy() {
if (this.hls) {
this.hls.destroy();
this.hls = null;
}
if (this.watchdogInterval) {
clearInterval(this.watchdogInterval);
this.watchdogInterval = null;
}
if (this.video) {
this.video.pause();
this.video.src = '';
}
}
}
class CFStreamManager {
constructor() {
this.players = new Map();
}
initAll() {
if (!window.CFStreamSources) return;
this.destroyAll();
for (const id in window.CFStreamSources) {
const player = new CFStreamPlayer(id, window.CFStreamSources[id]);
this.players.set(id, player);
}
}
destroyAll() {
this.players.forEach(player => player.destroy());
this.players.clear();
}
}
// Create global instance
window.cfStreamManager = new CFStreamManager();
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
window.cfStreamManager.initAll();
});
How to Use This
1. Set Up Your Video Sources
In your WordPress plugin or theme, output the video sources as a global JavaScript object:
function enqueue_stream_sources() {
$sources = [
'cfStream_' . $video_uid => $cloudflare_stream_url
];
wp_add_inline_script(
'cf-stream-player',
'window.CFStreamSources = ' . json_encode($sources) . ';',
'before'
);
}
add_action('wp_enqueue_scripts', 'enqueue_stream_sources');
2. Include HLS.js
Make sure HLS.js is loaded before your player script:
wp_enqueue_script(
'hls-js',
'https://cdn.jsdelivr.net/npm/hls.js@latest',
[],
null,
true
);
3. Wire Up Barba.js
In your Barba initialization:
barba.init({
transitions: [{
leave() {
if (window.cfStreamManager) {
window.cfStreamManager.destroyAll();
}
},
afterEnter() {
if (window.cfStreamManager) {
window.cfStreamManager.initAll();
}
}
}]
});
Key Takeaways
1. DOM Manipulation Libraries Need Special Handling with SPAs
Barba.js, Swup, or any page transition library will break your initialized components. Always plan for reinitialization.
2. Classes Provide Better Lifecycle Management
The destroy()
method is crucial for preventing memory leaks and cleaning up resources. Without it, you’re creating orphaned objects with every page transition.
3. Defensive Coding Saves Headaches
Those if (window.cfStreamManager)
checks prevent race conditions during page load and protect against missing dependencies.
4. Debug Systematically
When things don’t work, check in order:
- Is the class defined?
- Is the instance created?
- Are dependencies loaded?
- Are elements in the DOM?
- Are methods being called?
5. Script Loading Order Matters
Make sure your player code loads before your Barba initialization. Use WordPress’s wp_enqueue_script
dependencies array to enforce load order.
6. Watchdog Timers Are Your Friend
Live streams can stall for many reasons (network issues, server problems, codec errors). A simple watchdog timer that checks playback progress every few seconds can automatically recover from most issues.
Common Issues and Solutions
Video Doesn’t Initialize After Transition
Check: Is initAll()
being called in the afterEnter
hook?
Solution: Add a console.log to verify:
afterEnter() {
console.log('Reinitializing players...');
if (window.cfStreamManager) {
window.cfStreamManager.initAll();
}
}
Memory Usage Keeps Growing
Check: Are you calling destroyAll()
before transitions?
Solution: Always clean up in the leave
hook.
Multiple Players on Same Page Don’t Work
Check: Are all video IDs unique?
Solution: Ensure each video has a unique ID in both the HTML and the CFStreamSources
object.
HLS.js Not Loading
Check: Is HLS.js enqueued before your player script?
Solution: Use WordPress dependency arrays:
wp_enqueue_script('cf-stream-player', '...', ['hls-js'], null, true);
The Result
Smooth page transitions with uninterrupted video streaming. Players cleanly destroy and reinitialize with each navigation, preventing memory leaks and ensuring videos always work, no matter how users navigate your site.
Performance impact: Negligible. The destroy/reinit cycle takes milliseconds, and Barba’s transitions mask any brief interruption.
Browser compatibility: Works everywhere HLS.js works (all modern browsers).
Maintainability: Clean class structure makes it easy to extend with new features.
What’s Next?
Potential enhancements:
- Multi-quality streams with adaptive bitrate switching
- Analytics tracking (play/pause/stall events)
- Picture-in-picture support
- Stream health monitoring dashboard
- Automatic fallback to different CDNs
Happy streaming! 🎥
Got questions about implementing this in your own project? Drop a comment below or reach out.