Skip to content

RESOURCES / BLOG

Adaptive Bitrate HLS Delivery With Cloudinary and Next.js

Your video buffers. Netflix doesn’t. Here’s the difference, and how to close it with one URL parameter.

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.

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 vars never reach the browser bundle.
  • URL computation has zero client-side cost.
  • The HTML already contains the correct poster src 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:

  1. Quality badge overlay. Reads stats.qualityLabel from useHls and renders a QualityBadge that animates through tiers
  2. Score accumulation. A setInterval ticks every second and awards points based on the current quality level
  3. 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: setInterval every 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-vitals to 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.

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?
A progressive MP4 is encoded as a single static file at a fixed bitrate. The browser must download the chunks sequentially and fast enough to maintain playback. If network throughput falls below this fixed bitrate, the video stalls and buffers.

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?
Most modern browsers support Media Source Extensions (MSE), which allow libraries such as HLS.js to play .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?
Re-rendering loops can occur when parent-level callbacks, such as 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?
React state updater functions, such as the callback inside 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?
To prevent a video element from negatively affecting LCP, display an optimized poster image during the initial page render. Cloudinary can dynamically extract a frame from the video using URL transformation parameters such as 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?
With lazy transcoding, the first request for the transformed .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.

Start Using Cloudinary

Sign up for our free plan and start creating stunning visual experiences in minutes.

Sign Up for Free