Your video buffers. Netflix doesn’t. Here’s the difference, and how to close it with one URL parameter.
- Live demo: stream-video-like-a-pro-hls-cloudin.vercel.app
- Source code: github.com/musebe/stream-video-like-a-pro-hls-cloudinary-nextjs
Every tutorial ships a <video src="my-video.mp4" /> and calls it done. That works fine until your user is on a train with two bars of signal. Then it buffers. Then they leave.
The uncomfortable truth:
| Delivery Method | What Happens on Slow 3G |
|---|---|
| Progressive MP4 | Downloads at full bitrate or stalls |
| Adaptive HLS | Drops to 360p, keeps playing |
Progressive MP4 is a single file at a fixed bitrate. The player either downloads it fast enough to keep up, or it doesn’t. There’s no middle ground.
Netflix, YouTube, Twitch, and every other major streaming platform use adaptive bitrate (ABR) streaming. The player measures your bandwidth every few seconds and switches quality seamlessly. You never notice the switch. You just never buffer.
Setting this up used to mean FFmpeg pipelines, manifest generators, CDN configuration, and a headache. With Cloudinary, it’s a simple URL change.
ABR streaming works by pre-encoding the same video at multiple quality levels (the quality ladder) and packaging them into a manifest file the player reads.
| Level | Resolution | Typical Bitrate | Target Audience |
|---|---|---|---|
| 1 | 360p | ~400 kbps | Slow 3G |
| 2 | 480p | ~800 kbps | Fast 3G |
| 3 | 720p | ~2,000 kbps | 4G / WiFi |
| 4 | 1080p | ~4,500 kbps | Home broadband |
| 5 | 4K | ~15,000 kbps | Gigabit |
Every few seconds the player measures bandwidth, picks the highest quality tier it can sustain without buffering, and fetches the next segment from that tier. The switch is invisible.
HTTP Live Streaming (HLS), originally from Apple and now an IETF standard (RFC 8216), is the most widely supported ABR format on the web. It delivers content as short .ts segments (typically two to six seconds each), indexed by an .m3u8 playlist file.
# A simplified HLS master manifest
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=400000,RESOLUTION=640x360
360p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=1280x720
720p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1920x1080
1080p/index.m3u8
The player fetches this manifest, reads the available variants, picks one, and starts loading segments. When bandwidth changes, it switches variant. No interruption.
Here’s the entire Cloudinary HLS setup:
# Before — fixed quality MP4
https://res.cloudinary.com/<cloud>/video/upload/my-video.mp4
# After — full adaptive HLS
https://res.cloudinary.com/<cloud>/video/upload/sp_auto/my-video.m3u8
sp = streaming profile. auto = let Cloudinary choose the optimal quality ladder for the source video.
| Step | What Cloudinary Does |
|---|---|
| First request | Lazy-transcodes the source into multiple bitrate variants |
| Manifest | Generates an HLS master .m3u8 listing all variants |
| Segments | Produces .ts chunks for each quality tier |
| Delivery | Serves everything via Cloudinary’s CDN (300+ global PoPs) |
| Subsequent requests | Served instantly from CDN edge cache |
No FFmpeg, pipeline, or manifest generator. One URL change.
View the Cloudinary docs: Adaptive Bitrate Streaming
Instead of a boring embed, you built StreamLab, a gamified comparison arena where readers can feel the difference between HLS and MP4, not just read about it.
- Try it live: stream-video-like-a-pro-hls-cloudin.vercel.app
- Full source: github.com/musebe/stream-video-like-a-pro-hls-cloudinary-nextjs
| Feature | What It Shows |
|---|---|
| Side-by-side comparison | HLS sp_auto vs progressive MP4, same video |
| Network challenge board | Pick Fiber / WiFi / 4G / Slow 3G |
| Live stream intel | Bandwidth, buffer depth, dropped frames, quality switches |
| Quality badge | Animates through tiers as HLS.js switches levels |
| Score system | Points per second based on quality tier |
| Achievement badges | 5 badges tied to real streaming events |
| Interactive URL anatomy | Click each URL segment to learn what it does |
Server (page.tsx)
└── Builds Cloudinary URLs from env vars
└── Passes hlsSrc, mp4Src, poster to VideoArena
Client (video-arena.tsx → hls-player.tsx)
└── useHls() attaches HLS.js to <video>
└── Safari detected → native HLS instead
└── Stats, scores, achievements update in real time
The URL construction happens server-side; no env vars leak to the client, and the heavy HLS.js library is dynamically imported only when the component mounts.
| Layer | Choice | Why |
|---|---|---|
| Framework | Next.js 16.2 (Turbopack) | App Router, server components, fast HMR |
| Language | TypeScript 5 | Type-safe HLS quality levels and stats |
| Styling | Tailwind CSS v4 + Shadcn UI | Dark theme, zero config |
| HLS playback | HLS.js 1.6 | Full ABR in Chrome/Firefox |
| Native HLS | Browser-native | Zero-JS for Safari/iOS |
| Performance | web-vitals 5 | LCP measurement |
npx create-next-app@latest . --typescript --tailwind --app --src-dir
npx shadcn@latest init --defaults
npx shadcn@latest add badge card progress separator tabs tooltip
npm install hls.js web-vitals
Code language: CSS (css)
If you have a package-lock.json anywhere up your directory tree (common on developer machines), Turbopack may adopt the wrong workspace root and throw a React Client Manifest 500 error.
Fix it in next.config.ts:
const nextConfig: NextConfig = {
turbopack: {
root: process.cwd(), // anchor to the project, not the filesystem root
},
};
Code language: JavaScript (javascript)
Then delete .next/ and restart. One-time fix.
All URL construction lives in src/lib/cloudinary.ts. Pure functions, no SDK dependency.
hlsUrl({ cloudName, publicId })
// → https://res.cloudinary.com/<cloud>/video/upload/sp_auto/<id>.m3u8
mp4Url({ cloudName, publicId })
// → https://res.cloudinary.com/<cloud>/video/upload/<id>.mp4
posterUrl({ cloudName, publicId }, offsetSeconds = 2)
// → https://res.cloudinary.com/<cloud>/video/upload/so_2.0,pg_1/<id>.jpg
Code language: HTML, XML (xml)
The poster URL is worth highlighting. so_2.0 seeks 2 seconds in; pg_1 extracts that frame as an image. No separate upload. No pre-processing step. Cloudinary generates it on the first request and caches it.
URLs are built in page.tsx (a React Server Component) and passed down as props. This means:
- Env
varsnever reach the browser bundle. - URL computation has zero client-side cost.
- The HTML already contains the correct
postersrc on first paint, improving LCP.
// src/app/page.tsx
const config = { cloudName, publicId };
<VideoArena
hlsSrc={hlsUrl(config)}
mp4Src={mp4Url(config)}
poster={posterUrl(config, 2)}
/>
Code language: JavaScript (javascript)
See the full helpers:
src/lib/cloudinary.ts
The entire HLS lifecycle lives in src/hooks/use-hls.ts. Three design decisions made this nontrivial.
Chrome on macOS passes video.canPlayType("application/vnd.apple.mpegurl"), but stalls on Cloudinary’s HLS segments and emits no quality-level events. Using native HLS for Chrome produces a broken experience.
The fix: a user-agent regex that detects actual Safari:
function isActualSafari(): boolean {
return /^((?!chrome|android|crios|fxios).)*safari/i.test(navigator.userAgent);
}
Code language: JavaScript (javascript)
| Browser | Strategy |
|---|---|
| Safari, iOS WebKit | Native <video src=".m3u8"> |
| Chrome, Firefox, Edge | HLS.js via dynamic import() |
HLS.js loads via an asynchronous dynamic import. If the component unmounts before the import resolves, the callback fires on a dead component and calls setState into the void (React warning) or, worse, into a freshly mounted replacement (wrong data).
useEffect(() => {
let cancelled = false;
import("hls.js").then(({ default: Hls }) => {
if (cancelled) return; // component already gone
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
if (cancelled) return; // guard every async callback
setLevels(data.levels.map(/* ... */));
setIsLoaded(true);
});
});
return () => {
cancelled = true; // all callbacks check this before setState
hlsRef.current?.destroy();
};
}, [src, autoPlay]);
Code language: JavaScript (javascript)
onLevelSwitch and onEnded are callbacks from the parent. If they’re in the useEffect dep array, any time the parent re-renders with a new inline function reference, HLS tears down and re-initialises. Infinite loop.
// Store latest version in a ref — always current, never a dep
const onLevelSwitchRef = useRef(onLevelSwitch);
onLevelSwitchRef.current = onLevelSwitch;
// Inside the effect, call via ref — zero dep-array impact
hls.on(Hls.Events.LEVEL_SWITCHED, (_, data) => {
onLevelSwitchRef.current?.(data.level);
});
Code language: PHP (php)
Dep array stays [src, autoPlay]. HLS only restarts when the source actually changes.
Full hook implementation:
src/hooks/use-hls.ts
Three things the native <video> element can’t do on its own:
-
Quality badge overlay. Reads
stats.qualityLabelfromuseHlsand renders aQualityBadgethat animates through tiers -
Score accumulation. A
setIntervalticks every second and awards points based on the current quality level - Quality level pills. Shows every available HLS variant below the player, highlights the active one
<video
ref={videoRef}
poster={poster}
controls
playsInline
preload="auto" // start buffering immediately on mount
className="w-full rounded-lg aspect-video bg-zinc-900"
/>
Code language: HTML, XML (xml)
preload="auto" is the often-forgotten attribute that tells the browser to start filling the buffer without waiting for the user to hit play. Critical for LCP.
A standard <video> element with a stats panel that shows buffer depth, dropped frames, and load time, using the same metrics as the HLS player so the comparison is honest.
The key difference the stats reveal: MP4 shows no quality label because there’s only one. HLS shows the current tier and how many times it switched.
src/components/stats-panel.tsx surfaces six numbers that tell the real story:
| Stat | Source | What It Reveals |
|---|---|---|
| Quality | hls.levels[hls.currentLevel].height |
Current tier (360p → 4K) |
| Bandwidth | hls.bandwidthEstimate / 1000 |
HLS.js measured throughput in kbps |
| Buffer | video.buffered.end(n) - video.currentTime |
Seconds of video ready to play |
| Dropped frames | video.webkitDroppedFrameCount |
Decode failures (Chromium only) |
| Switches | Local counter on LEVEL_SWITCHED event |
How many times ABR kicked in |
| Score | Calculated in HlsPlayer |
Gamification metric |
One honest caveat: webkitDroppedFrameCount is a Chromium-only API. On Firefox and Safari it returns undefined and shows 0. The stats panel handles this gracefully with a nullish fallback.
Stats are updated two ways:
-
Event-driven: On
FRAG_LOADED(every segment fetch). -
Polled:
setIntervalevery 1 second for buffer depth (the video element doesn’t fire events for this).
The gamification lives in src/components/video-arena.tsx and src/types/video.ts.
Higher quality = more points per second. This makes the advantage of HLS immediately visible: a smooth 1080p stream scores 4x more than a buffering 360p one.
| Quality | pts/s | Why |
|---|---|---|
| Auto | 5 | Buffering / undetermined |
| 360p | 10 | Minimum viable |
| 480p | 15 | — |
| 720p | 25 | HD threshold |
| 1080p | 40 | Full HD |
| 4K | 60 | Maximum |
| Badge | Trigger | What It Teaches |
|---|---|---|
| 🚀 First Stream | Play starts | HLS.js loaded and attached |
| 🏄 Quality Surfer | 3+ level switches | ABR is actively adapting |
| 🏆 Bandwidth Master | All 4 presets tested | ABR works across conditions |
| ✅ HLS Convert | Stream reaches end | Reliable delivery end-to-end |
| 🎯 Score Hunter | 500 points | Higher quality = sustained reward |
The achievement unlock function needs to check whether a badge is already unlocked. The naive approach (closing over unlockedIds state) creates a new function identity every time an achievement unlocks, cascading new identities into every callback and triggering an infinite HLS re-init loop.
The fix: track unlocked IDs in a ref, not state, for the deduplication check.
const unlockedRef = useRef(new Set<string>());
const unlock = useCallback((id: string) => {
if (unlockedRef.current.has(id)) return; // ref check, not state
unlockedRef.current = new Set([...unlockedRef.current, id]);
setUnlockedIds(unlockedRef.current); // update state for the UI
setPendingAchievement(achievement);
}, []); // [] — permanently stable, no cascade
Code language: JavaScript (javascript)
Full implementation:
src/components/video-arena.tsx
The most effective way to teach a URL parameter is to make it clickable.
src/components/url-anatomy.tsx renders the full HLS URL as a row of interactive segments. Click any segment and a panel below explains exactly what it does.
https://res.cloudinary.com/<cloud>/video/upload/sp_auto/<id>.m3u8
│ │ │ │ │ │
CDN base Cloud Resource KEY ID Manifest
name type ↑
Click to learn
Each segment is a <button> with a colour, a label, and a description. No third-party library. Zero dependencies. Pure useState with a lookup table.
The sp_auto segment starts highlighted by default, as that’s the one thing readers need to remember.
Building this demo surfaced three nonobvious React hook bugs worth documenting.
// ❌ Calls parent setState inside a child setState updater
setScore((prev) => {
const next = prev + rate;
onScoreUpdate?.(next); // triggers parent's setScore → renders during render
return next;
});
// ✅ Call them sequentially, not nested
scoreRef.current += rate;
setScore(scoreRef.current);
onScoreUpdate?.(scoreRef.current);
Code language: JavaScript (javascript)
React’s state updater function must be pure. Calling another component’s setState inside it violates that contract and logs “Cannot update a component while rendering a different component.”
The original unlock closed over unlockedIds state to check for duplicates:
// ❌ unlock closes over state — new identity on every unlock
const unlock = useCallback((id: string) => {
if (unlockedIds.has(id)) return; // state in dep array
setUnlockedIds(prev => new Set([...prev, id]));
}, [unlockedIds]); // ← triggers the loop
Code language: JavaScript (javascript)
Calling unlock updates unlockedIds. That creates a new unlock. A new unlock creates a new handleScoreUpdate. A new handleScoreUpdate changes the onScoreUpdate prop. That prop change restarts the score useEffect, resets the interval, and fires onScoreUpdate again. Full circle.
The fix: move the duplicate check into a ref so unlock never needs state in its dep array.
// ✅ unlock reads a ref — identity never changes
const unlockedRef = useRef(new Set<string>());
const unlock = useCallback((id: string) => {
if (unlockedRef.current.has(id)) return; // ref, not state
unlockedRef.current = new Set([...unlockedRef.current, id]);
setUnlockedIds(unlockedRef.current); // state only for the UI
}, []); // ← permanently stable
Code language: JavaScript (javascript)
Rule: if a useCallback dep is state that changes as a result of calling that callback, you have a self-reinforcing loop. Move the check to a ref.
// ❌ The async callback ignores whether the effect was cleaned up
import("hls.js").then(({ default: Hls }) => {
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setIsLoaded(true); // fires on the old (cleaned-up) effect's HLS instance
});
});
// ✅ Cancelled flag blocks all post-cleanup setState calls
let cancelled = false;
import("hls.js").then(({ default: Hls }) => {
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (cancelled) return;
setIsLoaded(true);
});
});
return () => { cancelled = true; hls.destroy(); };
Code language: JavaScript (javascript)
In React 18 Strict Mode, effects intentionally mount → cleanup → remount. Without the cancelled flag, the first effect’s async callback sets state on the second mount’s component, producing the “Maximum update depth exceeded” loop.
Largest Contentful Paint (LCP) is the Core Web Vitals metric most affected by video delivery. A video with a slow-loading poster, or one that blocks the main thread, tanks LCP.
| Practice | Impact on LCP |
|---|---|
poster attribute from posterUrl() |
Image rendered before video loads, a direct LCP candidate |
preload="auto" |
Browser starts buffering on mount, not on play |
| Poster URL built server-side | In HTML on first paint, no client round-trip |
| HLS segments vs full MP4 | First segment (2–6 s of video) loads fast vs full file |
| Cloudinary CDN | Edge delivery reduces TTFB |
| Metric | Progressive MP4 | HLS sp_auto |
|---|---|---|
| Time to first frame | Waits for enough of the file | Loads first 360p segment (~100 KB) |
| Buffer behaviour | Stalls if download can’t keep up | Drops quality tier, keeps playing |
| LCP (poster) | Same | Same |
| Rebuffering events | Frequent on slow connections | Near-zero |
Install
web-vitalsto instrument LCP in your own pages: web.dev/vitals Cloudinary’s Video Optimization guide covers additional LCP wins like format selection and codec settings.
# Local
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud
NEXT_PUBLIC_CLOUDINARY_VIDEO_PUBLIC_ID=your-video-id
# Vercel dashboard → Settings → Environment Variables → same keys
Code language: PHP (php)
Both variables are NEXT_PUBLIC_, inlined at build time into the client bundle. Safe because they’re already public (they appear in every video URL your browser loads).
sp_auto is a lazy transformation. The first request to your-video.m3u8 triggers Cloudinary to transcode the source into HLS variants. The manifest returns quickly; the first segments may buffer for 10–30 seconds while transcoding completes.
A pre-warm strategy would be to make an HTTP request to the .m3u8 URL from your CI/CD pipeline or a serverless function after each deploy. Subsequent loads hit the CDN edge cache instantly.
# Simple pre-warm in your deploy script
curl -s "https://res.cloudinary.com/<cloud>/video/upload/sp_auto/<id>.m3u8" > /dev/null
Code language: PHP (php)
Alternatively, use Cloudinary’s eager transformations at upload time to pregenerate HLS segments before any user requests them.
The StreamLab demo is live at stream-video-like-a-pro-hls-cloudin.vercel.app, deployed on Vercel’s Edge Network backed by Cloudinary’s CDN for the media assets.
| Capability | DIY setup | Cloudinary sp_auto |
|---|---|---|
| Multi-bitrate encoding | FFmpeg pipeline | ✅ Automatic |
| HLS manifest generation | Custom tooling | ✅ Automatic |
| Quality ladder selection | Manual | ✅ Optimised per source |
| CDN delivery | Separate CDN config | ✅ Included |
| Poster frame extraction | Separate encode | ✅ so_N.0,pg_1 param |
| First-load processing | Pre-run pipeline | ✅ Lazy on first request |
| Situation | Recommendation |
|---|---|
| Short clips < 30 s | MP4 — simpler, fast enough |
| Course / tutorial videos | HLS — viewers on all connections |
| Live streaming | HLS (with low-latency mode) |
| Mobile-first audience | HLS — non-negotiable |
| Background / hero video | MP4 + muted autoplay playsInline |
| Logged-in product video | HLS — you care about completion rate |
Before: https://res.cloudinary.com/<cloud>/video/upload/<id>.mp4
After: https://res.cloudinary.com/<cloud>/video/upload/sp_auto/<id>.m3u8
That’s it. With one parameter, you get full adaptive streaming.
Ready to start building with Cloudinary? Sign up for a free account today.
- Cloudinary Adaptive Bitrate Streaming
- Cloudinary Video Optimization Guide
- Cloudinary Eager Transformations
- HLS.js Documentation
- IETF RFC 8216 — HTTP Live Streaming
- Core Web Vitals — LCP
- Next.js App Router
| File | What’s Inside |
|---|---|
src/lib/cloudinary.ts |
URL builders |
src/hooks/use-hls.ts |
HLS.js hook |
src/components/hls-player.tsx |
HLS player |
src/components/mp4-player.tsx |
MP4 baseline |
src/components/video-arena.tsx |
Main orchestrator |
src/components/url-anatomy.tsx |
Interactive URL breakdown |
src/types/video.ts |
Types, scoring, achievements |
next.config.ts |
Turbopack root fix |
Built with Next.js 16.2, Cloudinary, HLS.js, Tailwind CSS, and Shadcn UI. Full source at github.com/musebe/stream-video-like-a-pro-hls-cloudinary-nextjs.
Why does a progressive MP4 buffer on slow network connections compared to Adaptive HLS?
Adaptive HLS (HTTP Live Streaming) solves this by splitting the video into multiple quality tiers, known as a quality ladder, and chunking them into short 2–6 second segments. The player continually measures real-time network bandwidth and switches seamlessly to lower-bitrate segments, such as 360p, on slow connections to prevent playback interruption. It raises the quality back to 1080p or 4K when bandwidth recovers.
What is Cloudinary’s sp_auto parameter, and how does it generate HLS files?
sp_auto stands for “Streaming Profile: Auto.” When appended as a transformation segment to a Cloudinary delivery URL, such as /video/upload/sp_auto/video-id.m3u8, Cloudinary handles the adaptive bitrate workflow.
It analyzes the source video, creates an optimized multi-bitrate quality ladder, transcodes the video into those variants, generates the master
.m3u8 playlist, and splits the variants into streaming segments. The resulting assets are delivered and cached through the CDN, eliminating the need for manual FFmpeg configurations or custom origin infrastructure.
How do you handle Safari native HLS playback versus Chrome using HLS.js?
.m3u8 manifests. Safari and iOS WebKit can play HLS natively through the HTML5 <video> element.
A custom hook such as
useHls should perform a browser capability check. Because Chrome on macOS can return a false positive for native HLS support while failing to provide required stream statistics events, the implementation should detect Safari explicitly. For example:
/^((?!chrome|android|crios|fxios).)*safari/i.test(navigator.userAgent)
Native Safari can use
<video src="...m3u8">, while Chrome, Edge, and Firefox can dynamically load HLS.js and bind it to the video element.
How do you solve React re-rendering loops caused by state updates in asynchronous HLS.js callbacks?
onLevelSwitch or onEnded, are included in the dependency array of the useEffect that manages the HLS.js lifecycle.
If the parent recreates those inline functions on every render, the effect repeatedly tears down and reinitializes HLS.js. The solution is the callback ref pattern: Store the latest callback references in a React
useRef, such as onLevelSwitchRef.current = onLevelSwitch.
The ref always contains the latest function without triggering the effect again, allowing the dependency array to remain stable, such as
[src, autoPlay].
Why does updating parent state inside a state updater function cause a React error, and how do you resolve it?
setScore(prev => ...), must remain pure and free of side effects. Calling a parent state setter, such as onScoreUpdate?.(next), from inside another component’s state updater can schedule a new render before the current render finishes.
This can produce the error:
Cannot update a component while rendering a different component.
To resolve it, separate the state update from the side effect. Calculate or track the next value using a local variable or
useRef, update the component’s state, and invoke the parent callback separately rather than nesting it inside the updater function.
How do you optimize Largest Contentful Paint (LCP) for video elements using Cloudinary posters?
so_2.0 and pg_1, which seek to two seconds and extract a frame.
The resulting poster can be delivered as a compressed JPG, WebP, or another optimized image format. When the poster URL is generated server-side, such as within a Next.js App Router Server Component, the image source is included in the initial HTML response.
Pairing the poster with an appropriate video preload strategy can improve time to first frame and overall Core Web Vitals performance.
What is the difference between lazy and eager transcoding in Cloudinary’s HLS generation?
.m3u8 URL triggers the HLS generation process. The manifest may become available quickly, but the first playback request can experience a delay while the initial variants and segments are generated.
To avoid this first-request penalty in production, you can prewarm the HLS URL after upload or deployment by requesting it before users do. You can also configure eager transformations during upload so Cloudinary generates the HLS quality layers and manifest assets in advance.