DEV Community

med19999
med19999

Posted on • Originally published at streamvexa.com

How to Build a Seamless 4K HLS Video Player in React & Next.js

Building a high-performance video player for the web is harder than throwing a <video> tag on a page. When you're dealing with 4K Live Streams and HTTP Live Streaming (HLS), issues like buffer stalling, memory leaks, and browser inconsistencies can quickly ruin the user experience.

While engineering the frontend architecture for StreamVexa—a premium 4K streaming platform—we had to ensure 100% stable playback across browsers, smart TVs, and mobile devices.

Here is exactly how we built a bulletproof HLS player component in React and Next.js, and the pitfalls you should avoid.

The Problem with Native <video>

Modern browsers like Safari natively support HLS (.m3u8 files). However, Chrome, Firefox, and Edge do not natively understand the format. If you try to pass an M3U8 stream to a standard video tag on Chrome, it simply fails.

To bridge this gap, the community relies on hls.js, a brilliant JavaScript library that transmuxes HLS into Fragmented MP4 (fMP4) and feeds it to the browser's Media Source Extensions (MSE) API.

Building the React Component

Our goal is to create a reusable <VideoPlayer /> component that dynamically attaches hls.js when needed, prefers native playback when available, and strictly cleans up after itself to prevent memory leaks in our Next.js application.

Step 1: The Basic Setup

First, install the library:

npm install hls.js
Enter fullscreen mode Exit fullscreen mode

Next, create the core component structure:

import React, { useEffect, useRef } from 'react';
import Hls from 'hls.js';

interface VideoPlayerProps {
  streamUrl: string;
}

export default function VideoPlayer({ streamUrl }: VideoPlayerProps) {
  const videoRef = useRef<HTMLVideoElement>(null);

  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;

    let hls: Hls;

    if (Hls.isSupported()) {
      hls = new Hls({
        maxBufferLength: 30, // Keep 30 seconds buffered ahead
        maxMaxBufferLength: 60,
        enableWorker: true, // Offload parsing to Web Worker to prevent UI lag
      });

      hls.loadSource(streamUrl);
      hls.attachMedia(video);

      hls.on(Hls.Events.MANIFEST_PARSED, () => {
        video.play().catch((err) => console.log('Autoplay blocked by browser:', err));
      });
    } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
      // Native support fallback (i.e., Safari on iOS/macOS)
      video.src = streamUrl;
      video.addEventListener('loadedmetadata', () => {
        video.play().catch((err) => console.log('Autoplay blocked by browser:', err));
      });
    }

    // CRITICAL: Cleanup on unmount
    return () => {
      if (hls) {
        hls.destroy();
      }
    };
  }, [streamUrl]);

  return (
    <div className="video-container bg-black rounded-xl overflow-hidden aspect-video relative">
      <video ref={videoRef} className="w-full h-full" controls playsInline />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Three Crucial Optimizations We Learned in Production

At StreamVexa, we noticed that naive implementations of HLS resulted in micro-stutters during high-action live sporting events. We implemented these three stability fixes:

1. Prevent V8 Memory Leaks

Single Page Applications (SPAs) like Next.js don't reload the entire browser window between navigations. If a user navigates away from the player page without explicitly calling hls.destroy(), the orphaned HLS instance keeps running, downloading video fragments continuously in the background. In our useEffect cleanup function, destroying the instance handles this perfectly.

2. Intelligently Configure Buffer Lengths

By default, hls.js might try to buffer a massive amount of video data into RAM. This is fine on a MacBook pro, but causes immediate crashes out-of-memory crashes on low-end smart TVs or older Firesticks.

const hls = new Hls({
  maxBufferLength: 30, 
  maxMaxBufferLength: 60,
});
Enter fullscreen mode Exit fullscreen mode

Capping the buffer logic directly ensures stable memory usage while providing enough runway for intermittent network drops.

3. Handle Strict Autoplay Policies

Modern browsers rightfully block autoplaying videos that have unmuted sound. If you try to call .play() automatically, it will throw a Javascript unhandled promise rejection error. Always catch the .play() promise rejection, and ideally, start the video muted <video muted controls playsInline ... /> if autoplay is an absolute requirement for your initial UI design.

Conclusion

Combining React's powerful useEffect lifecycle hooks with hls.js provides a robust, cross-browser video streaming experience. By properly handling native Safari fallback support, explicitly controlling buffer sizes for low-end devices, and executing strict memory destruction, your users will enjoy a seamless viewing experience without draining their device resources.

If you want to experience high-quality live video architecture in action without writing the infrastructure yourself, check out what we are building over at StreamVexa. Keep shipping!

Top comments (0)