Build a custom HLS player in React with hls.js (no wrapper libraries)
Engineering a Lightweight HLS Playback Engine in React 19
Current Situation Analysis
Modern web applications frequently require adaptive bitrate streaming, yet the default approach remains installing monolithic player wrappers. Libraries like Video.js, Plyr, or ReactPlayer bundle extensive DOM manipulation layers, default CSS skins, plugin architectures, and legacy fallbacks. For most production environments, these abstractions introduce unnecessary overhead. The actual requirements for HLS playback are narrowly scoped: a Media Source Extensions (MSE) shim to parse fragmented MP4 segments, and a deterministic error-recovery loop to handle network instability or decoder failures.
The industry consistently overlooks this distinction. Teams assume that because HLS requires JavaScript-level segment fetching and buffer management, a full wrapper is mandatory. In reality, hls.js 1.6.x handles the MSE lifecycle, ABR (Adaptive Bitrate) negotiation, and fragment scheduling. The browser's native <video> element already exposes playback controls, time updates, and buffer ranges. Wrapping these two primitives in a heavy abstraction layer typically adds 150β200KB to the client bundle, obscures real-time buffer telemetry, and forces developers to fight against pre-built UI components when custom UX is required.
Data from production deployments consistently shows that custom hls.js integrations reduce initial JavaScript payload by 40β60% compared to wrapper-based implementations. More critically, improper lifecycle management in wrappers leads to silent MSE memory leaks. React 19's StrictMode double-mounts effects in development, which exposes cleanup failures immediately. Without explicit destroy() calls, each remount spawns a new Hls instance that retains attached source buffers, causing heap growth that eventually triggers browser tab crashes. Understanding the exact boundary between what hls.js provides and what the application must manage is the foundation of a production-ready streaming architecture.
WOW Moment: Key Findings
The following comparison illustrates the operational differences between relying on a wrapper library and implementing a direct hls.js integration.
| Approach | Bundle Size (Gzipped) | Control Granularity | Error Recovery Transparency | Memory Footprint |
|---|---|---|---|---|
| Wrapper Library | 150β200 KB | Low (pre-styled DOM, limited API hooks) | Low (opaque retry logic, hidden state) | High (persistent DOM nodes, unmanaged MSE refs) |
Custom hls.js Integration |
~80 KB | High (direct <video> API access, custom state sync) |
High (explicit fatal/non-fatal branching, rate-limited recovery) | Low (explicit destroy() lifecycle, ref-scoped instances) |
This finding matters because streaming performance is directly tied to how quickly the player can react to network degradation and how cleanly it releases MSE resources. Custom integration exposes Hls.Events directly, allowing engineers to implement telemetry, buffer visualization, and recovery strategies that match exact product requirements. It also eliminates the CSS/JS coupling that forces teams to override wrapper styles or disable default controls, reducing maintenance debt across UI iterations.
Core Solution
Building a production-grade HLS engine requires separating three concerns: lifecycle management, state synchronization, and error recovery. The architecture below uses a custom hook to own the Hls instance, a controller object to expose playback actions, and event-driven state updates to keep the UI in sync without excessive re-renders.
Step 1: Engine Lifecycle Hook
The hook must handle three paths: Safari native playback, hls.js initialization, and strict cleanup. React 19's concurrent rendering and StrictMode demand explicit reference management to prevent duplicate instance creation.
import { useEffect, useRef, useCallback } from "react";
import Hls from "hls.js";
export interface StreamConfig {
src: string;
lowLatency?: boolean;
backBuffer?: number;
maxBufferLength?: number;
}
export function useStreamEngine({ src, lowLatency = false, backBuffer = 30, maxBufferLength = 60 }: StreamConfig) {
const videoEl = useRef<HTMLVideoElement | null>(null);
const engineRef = useRef<Hls | null>(null);
const isMounted = useRef(false);
const initialize = useCallback(() => {
if (!videoEl.current || isMounted.current) return;
isMounted.current = true;
const nativeCheck = videoEl.current.canPlayType("application/vnd.apple.mpegurl");
if (nativeCheck) {
videoEl.current.src = src;
return;
}
if (!Hls.isSupported()) return;
const instance = new Hls({
enableWorker: true,
lowLatencyMode: lowLatency,
backBufferLength: backBuffer,
maxBufferLength: maxBufferLength,
maxMaxBufferLength: 120,
});
instance.loadSource(src);
instance.attachMedia(videoEl.current);
engineRef.current = instance;
}, [src, lowLatency, backBuffer, maxBufferLength]);
const teardown = useCallback(() => {
if (engineRef.current) {
engineRef.current.destroy();
engineRef.current = null;
}
isMounted.current = false;
}, []);
useEffect(() => {
initialize();
return teardown;
}, [initialize, teardown]);
return { videoEl, engineRef, teardown };
}
Architecture Rationale:
isMountedref prevents double-initialization during StrictMode dev cycles.engineRefkeeps theHlsinstance outside React's render cycle, avoiding stale closure issues.- Configuration parameters are exposed as props to allow environment-specific tuning (e.g., lower
maxBufferLengthfor mobile networks).
Step 2: Playback Controller & State Sync
Direct DOM manipulation inside event handlers causes race conditions. Instead, expose a controller that mutates the video element and syncs critical state through a single effect.
import { useState, useEffect, useRef } from "react";
export interface PlaybackState {
isPlaying: boolean;
currentTime: number;
duration: number;
currentQuality: string;
isBuffering: boolean;
}
export function usePlaybackController(videoEl: React.RefObject<HTMLVideoElement | null>) {
const [state, setState] = useState<PlaybackState>({
isPlaying: false,
currentTime: 0,
duration: 0,
currentQuality: "auto",
isBuffering: false,
});
const rafId = useRef<number>(0);
useEffect(() => {
const el = videoEl.current;
if (!el) return;
const syncTime = () => {
setState(prev => ({
...prev,
currentTime: el.currentTime,
duration: el.duration || 0,
}));
rafId.current = requestAnimationFrame(syncTime);
};
const onPlay = () => setState(prev => ({ ...prev, isPlaying: true }));
const onPause = () => setState(prev => ({ ...prev, isPlaying: false }));
const onWaiting = () => setState(prev => ({ ...prev, isBuffering: true }));
const onPlaying = () => setState(prev => ({ ...prev, isBuffering: false }));
el.addEventListener("play", onPlay);
el.addEventListener("pause", onPause);
el.addEventListener("waiting", onWaiting);
el.addEventListener("playing", onPlaying);
el.addEventListener("loadedmetadata", () => {
setState(prev => ({ ...prev, duration: el.duration }));
});
rafId.current = requestAnimationFrame(syncTime);
return () => {
cancelAnimationFrame(rafId.current);
el.removeEventListener("play", onPlay);
el.removeEventListener("pause", onPause);
el.removeEventListener("waiting", onWaiting);
el.removeEventListener("playing", onPlaying);
};
}, [videoEl]);
const controls = {
togglePlay: () => {
const el = videoEl.current;
if (!el) return;
el.paused ? el.play().catch(() => {}) : el.pause();
},
seek: (time: number) => {
if (videoEl.current) videoEl.current.currentTime = time;
},
setVolume: (vol: number) => {
if (videoEl.current) videoEl.current.volume = Math.max(0, Math.min(1, vol));
},
};
return { state, controls };
}
Architecture Rationale:
requestAnimationFramesyncs time updates at display refresh rate, preventing React re-render thrashing fromtimeupdateevents.- Event listeners are attached directly to the element, bypassing React's synthetic event system for performance-critical media events.
- Controller functions are memoized implicitly through closure, ensuring stable references for UI components.
Step 3: Quality Selection & Buffer Visualization
ABR state and buffer ranges require direct access to hls.js internals. Expose them through a dedicated selector hook.
export function useQualitySelector(engineRef: React.RefObject<Hls | null>) {
const [levels, setLevels] = useState<Hls.Level[]>([]);
useEffect(() => {
const hls = engineRef.current;
if (!hls) return;
const onManifestParsed = () => {
setLevels(hls.levels);
};
const onLevelSwitch = ({ level }: { level: number }) => {
const active = hls.levels[level];
if (active) {
// Update UI quality readout via parent state or context
window.dispatchEvent(new CustomEvent("quality-changed", { detail: `${active.height}p` }));
}
};
hls.on(Hls.Events.MANIFEST_PARSED, onManifestParsed);
hls.on(Hls.Events.LEVEL_SWITCHED, onLevelSwitch);
return () => {
hls.off(Hls.Events.MANIFEST_PARSED, onManifestParsed);
hls.off(Hls.Events.LEVEL_SWITCHED, onLevelSwitch);
};
}, [engineRef]);
const setQuality = (index: number) => {
if (engineRef.current) {
engineRef.current.currentLevel = index;
}
};
return { levels, setQuality };
}
Buffer visualization uses the native buffered TimeRanges object. Calculate ahead percentage without blocking the main thread:
export function getBufferAhead(videoEl: HTMLVideoElement | null, duration: number): number {
if (!videoEl || !duration || !videoEl.buffered.length) return 0;
const end = videoEl.buffered.end(videoEl.buffered.length - 1);
return Math.min((end / duration) * 100, 100);
}
Step 4: Deterministic Error Recovery
Wrapper libraries hide error handling behind generic retry counters. Production streams require explicit branching based on error type and fatal status.
export function attachErrorRecovery(engineRef: React.RefObject<Hls | null>) {
const hls = engineRef.current;
if (!hls) return;
let recoveryAttempts = 0;
const lastRecoveryTime = { current: 0 };
hls.on(Hls.Events.ERROR, (_, data) => {
if (!data.fatal) return;
const now = Date.now();
if (now - lastRecoveryTime.current < 3000) {
recoveryAttempts++;
} else {
recoveryAttempts = 0;
}
lastRecoveryTime.current = now;
if (recoveryAttempts >= 2) {
console.error("hls: recovery limit reached, tearing down engine");
hls.destroy();
return;
}
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
console.warn("hls: network failure, restarting fragment load");
hls.startLoad();
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
console.warn("hls: decoder failure, rebuilding source buffers");
hls.recoverMediaError();
} else {
console.error("hls: unhandled fatal error", data.details);
hls.destroy();
}
});
}
Architecture Rationale:
- Rate limiting prevents infinite recovery loops when the CDN serves consistently corrupted segments.
startLoad()resets the fragment scheduler without tearing down MSE, ideal for transient network drops.recoverMediaError()reconstructs source buffers, the only reliable fix for decoder exhaustion after malformed fMP4 segments.- Fatal non-recoverable errors trigger teardown, allowing the UI to display a fallback state or prompt refresh.
Pitfall Guide
1. StrictMode Double-Mount Memory Leak
Explanation: React 19 StrictMode invokes effects twice in development. Without a mount guard or explicit cleanup, new Hls() runs twice, attaching two MSE instances to the same <video> element. The first instance is never destroyed, leaking source buffers and worker threads.
Fix: Use a useRef mount flag or ensure the cleanup function always calls hls.destroy(). Never rely on React's automatic cleanup for MSE resources.
2. Safari Native HLS Bypass Failure
Explanation: iOS and macOS Safari support HLS natively via application/vnd.apple.mpegurl. Initializing hls.js on these platforms creates a duplicate parser, causing audio desync and increased memory usage.
Fix: Always check video.canPlayType("application/vnd.apple.mpegurl") before instantiating Hls. If true, assign video.src = manifestUrl and skip hls.js entirely.
3. Confusing pause with waiting for Buffer UI
Explanation: The pause event fires when the user manually stops playback. Using it to trigger a "buffering" indicator creates false positives. The waiting event specifically indicates the playback position has caught up to the buffered range.
Fix: Bind buffer indicators to waiting and playing events. Ignore pause for network state visualization.
4. Infinite Recovery Loops on Media Errors
Explanation: recoverMediaError() rebuilds MSE buffers but does not fix corrupted source segments. If the CDN repeatedly serves broken fragments, calling recovery repeatedly wedges the player in a loop, freezing the UI thread.
Fix: Implement a time-windowed attempt counter. If two recovery calls occur within 3 seconds, destroy the engine and transition to a terminal error state.
5. Ignoring Hls.isSupported() Fallback
Explanation: Older browsers or restricted environments (certain WebView configurations) lack MSE support. Failing to check isSupported() results in a silent failure with no user feedback.
Fix: Always guard initialization with if (!Hls.isSupported()). Provide a fallback UI, native HLS redirect, or graceful degradation message.
6. ABR State Desynchronization
Explanation: Manually setting hls.currentLevel does not automatically update UI state if the component relies on LEVEL_SWITCHED events that fire asynchronously. Rapid user interactions can cause the displayed quality to lag behind the actual rendered level.
Fix: Read hls.levels[hls.currentLevel] directly when updating the selector. Debounce UI updates or use a custom event dispatch to ensure immediate visual feedback.
7. Subtitle Track Index Mismatch
Explanation: hls.subtitleTracks is populated after MANIFEST_PARSED. Attempting to set hls.subtitleTrack before the array is ready throws an index out of bounds error or silently fails.
Fix: Wait for the manifest parsed event, map track indices to UI options, and only enable tracks after confirming hls.subtitleTracks.length > 0. Use -1 to explicitly disable captions.
Production Bundle
Action Checklist
- Verify Safari native HLS path: Check
canPlayTypebefore initializinghls.js - Implement strict cleanup: Call
hls.destroy()in effect teardown and on error termination - Add recovery rate limiting: Track recovery attempts within a 3-second window to prevent infinite loops
- Sync buffer state correctly: Use
waiting/playingevents, notpause, for network indicators - Expose telemetry hooks: Subscribe to
FRAG_LOADEDandLEVEL_SWITCHEDfor CDN performance monitoring - Handle unsupported browsers: Guard with
Hls.isSupported()and render fallback UI - Tune buffer parameters: Adjust
maxBufferLengthandbackBufferLengthbased on target device capabilities - Validate subtitle initialization: Only set track indices after
MANIFEST_PARSEDfires
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Internal dashboard / low traffic | Wrapper library | Faster initial delivery, built-in UI, acceptable bundle size | Low dev cost, higher bandwidth/CDN cost |
| Consumer streaming app / scale | Custom hls.js integration |
Precise control, minimal bundle, direct telemetry access | Higher initial dev cost, lower long-term infra cost |
| Low-latency live events | lowLatencyMode: true + maxBufferLength: 10 |
Reduces playback delay to ~3-5s, prioritizes freshness over stability | Slightly higher rebuffer risk, requires robust CDN |
| Metered / mobile networks | Manual quality selector + maxMaxBufferLength: 30 |
Prevents aggressive ABR upshifts, gives users data control | Reduced CDN egress, improved user retention |
Configuration Template
import Hls from "hls.js";
export const DEFAULT_HLS_CONFIG: Hls.Config = {
enableWorker: true,
lowLatencyMode: false,
backBufferLength: 30,
maxBufferLength: 60,
maxMaxBufferLength: 120,
maxBufferSize: 60 * 1000 * 1000, // 60MB
maxBufferHole: 0.5,
fragLoadingTimeOut: 20000,
fragLoadingMaxRetry: 6,
fragLoadingRetryDelay: 1000,
manifestLoadingTimeOut: 10000,
manifestLoadingMaxRetry: 4,
manifestLoadingRetryDelay: 1000,
startLevel: -1, // Auto ABR
abrEwmaDefaultEstimate: 500000, // 500kbps initial bandwidth guess
testBandwidth: true,
};
Quick Start Guide
- Initialize project: Run
npm create vite@latest stream-engine -- --template react-ts, thennpm install hls.js. - Create engine hook: Copy the
useStreamEngineimplementation intosrc/hooks/useStreamEngine.ts. Export thevideoElref andengineRef. - Wire playback controls: Implement
usePlaybackControllerto sync time, play state, and buffer status. AttachtogglePlay,seek, andsetVolumeto UI elements. - Attach error recovery: Call
attachErrorRecovery(engineRef)immediately afterhls.jsinitialization. Verify console warnings appear on simulated network drops. - Deploy test stream: Use Apple's public bipbop manifest (
https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8) to validate ABR switching, buffer visualization, and cleanup behavior across Chrome, Firefox, and Safari.
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
