Fixing HLS Video Players with Barba.js: A Debugging Journey

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?

  1. Memory management – Classes let us properly clean up HLS instances before page transitions
  2. State tracking – Each player instance maintains its own state (watchdog timer, stall counter, HLS instance)
  3. Scalability – Easy to manage multiple video players on a page
  4. 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:

  1. User clicks a link
  2. Barba’s leave hook fires → destroy all players
  3. Barba fetches new content
  4. Barba swaps the DOM
  5. Barba’s afterEnter hook fires → reinitialize all players
  6. 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.

← Back to main build journey

💬