When you want to serve video through a CDN, the first question is always: what format should the output be in? Serving an MP4 directly seems like the easy path, but that locks every viewer into a single bitrate regardless of their connection speed. It also makes CDN caching less efficient because seeking triggers range requests directly against the origin.
HLS (HTTP Live Streaming) solves both problems. It splits video into small segments and serves them as plain HTTP files — exactly what CDNs are built to cache. Add FFmpeg to the mix and you can convert any source file to HLS in a single command.
This guide walks through the full pipeline: single-bitrate HLS encoding, adaptive bitrate (ABR) with a master playlist, uploading to S3 and serving through CloudFront, CORS configuration, and playing back in the browser with hls.js.
What Is HLS
HLS (HTTP Live Streaming) is a streaming protocol developed by Apple. It works by splitting a video into small segments (.ts files, typically a few seconds each) and providing an index file called a playlist (.m3u8) that lists all the segments in order.
Compared to serving a plain MP4, HLS offers three main advantages:
- Segment-level caching: Each segment is an independent HTTP request, which CDNs cache with high efficiency
- Adaptive bitrate (ABR): The player switches quality levels in real time based on available bandwidth (when using a master playlist)
- Efficient seeking: The player only fetches the segment that contains the requested timestamp, making scrubbing through long videos fast
There are two types of playlists. A media playlist lists the segments for a single quality level. A master playlist lists multiple media playlists, one per quality level — that is the entry point when ABR is in use.
Single-Bitrate HLS Encoding
Start with the simplest case: converting one video file to HLS at a single bitrate.
ffmpeg -i input.mp4 \
-c:v libx264 \
-preset fast \
-crf 22 \
-c:a aac \
-b:a 128k \
-hls_time 6 \
-hls_list_size 0 \
-hls_segment_filename "output/segment_%04d.ts" \
output/index.m3u8
Here is what each relevant flag does:
| Flag | Purpose |
|---|---|
-hls_time 6 | Target segment duration in seconds. 4–10 seconds is common for VOD |
-hls_list_size 0 | Keep all segments in the playlist (live streaming uses a different value) |
-hls_segment_filename | Path pattern for segment output files |
-crf 22 | Quality level — lower is better quality and larger file size. 18–28 is the practical range |
-preset fast | Trade-off between encoding speed and compression. slow gives smaller files, fast runs quicker |
After running the command, the output/ directory will contain:
output/
├── index.m3u8 # Playlist file
├── segment_0000.ts # First segment (~6 seconds)
├── segment_0001.ts
├── segment_0002.ts
└── ...
Upload this directory to any static host or CDN and it is ready to play.
Building Adaptive Bitrate (ABR) Streams
With a single bitrate, viewers on slow connections receive the same file as viewers on fast connections — which leads to buffering for some and wasted bandwidth for others. ABR lets the player measure connection speed and automatically select the best quality level for each viewer.
Encode three quality levels separately:
# Low quality (360p, ~500 kbps)
ffmpeg -i input.mp4 \
-c:v libx264 -preset fast -crf 28 \
-vf "scale=640:360" \
-c:a aac -b:a 96k \
-hls_time 6 -hls_list_size 0 \
-hls_segment_filename "output/360p/segment_%04d.ts" \
output/360p/index.m3u8
# Medium quality (720p, ~1500 kbps)
ffmpeg -i input.mp4 \
-c:v libx264 -preset fast -crf 23 \
-vf "scale=1280:720" \
-c:a aac -b:a 128k \
-hls_time 6 -hls_list_size 0 \
-hls_segment_filename "output/720p/segment_%04d.ts" \
output/720p/index.m3u8
# High quality (1080p, ~3000 kbps)
ffmpeg -i input.mp4 \
-c:v libx264 -preset fast -crf 20 \
-vf "scale=1920:1080" \
-c:a aac -b:a 192k \
-hls_time 6 -hls_list_size 0 \
-hls_segment_filename "output/1080p/segment_%04d.ts" \
output/1080p/index.m3u8
Once the three encodes finish, create the master playlist manually:
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=640x360,CODECS="avc1.42e01e,mp4a.40.2"
360p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1500000,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2"
720p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2"
1080p/index.m3u8
Save this as output/master.m3u8. The player loads this file first, then switches between the media playlists at runtime based on measured bandwidth. The final directory layout looks like this:
output/
├── master.m3u8 # Master playlist (player entry point)
├── 360p/
│ ├── index.m3u8
│ ├── segment_0000.ts
│ └── ...
├── 720p/
│ ├── index.m3u8
│ └── ...
└── 1080p/
├── index.m3u8
└── ...
Uploading to a CDN
The most common setup is S3 (or an S3-compatible store like Cloudflare R2) as origin storage, with CloudFront or Cloudflare in front for caching and global distribution.
Upload the files with the AWS CLI, applying different cache headers for playlists and segments:
# Upload m3u8 playlists — disable caching so players always get the latest version
aws s3 sync output/ s3://your-bucket-name/videos/sample/ \
--exclude "*" \
--include "*.m3u8" \
--content-type "application/vnd.apple.mpegurl" \
--cache-control "no-cache, no-store"
# Upload ts segments — cache aggressively since segment files never change
aws s3 sync output/ s3://your-bucket-name/videos/sample/ \
--exclude "*" \
--include "*.ts" \
--content-type "video/mp2t" \
--cache-control "public, max-age=31536000, immutable"
The cache strategy matters here:
- m3u8 playlists: Use
no-cache. A playlist can change if segments are added (live) or the video is re-encoded. You want players to always fetch a fresh copy. - ts segments: Use
max-age=31536000(one year). Segment filenames contain a sequence number that never changes, so the content is immutable — let the CDN cache it indefinitely.
If you use CloudFront, configure separate cache behaviors for *.m3u8 and *.ts to apply these policies independently.
CORS and Security Configuration
When hls.js loads segments from a CDN on a different origin than your website, the browser enforces CORS. Without the correct headers, the browser will block every segment request and the video will not play.
Apply a CORS rule to the S3 bucket:
# Create the config file and apply it
cat > cors-config.json << 'EOF'
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedOrigins": ["https://your-site.com"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
EOF
aws s3api put-bucket-cors \
--bucket your-bucket-name \
--cors-configuration file://cors-config.json
For CloudFront, add a response headers policy that includes Access-Control-Allow-Origin. In the AWS Console, go to CloudFront → Response Headers Policies → Create policy and add the CORS headers there.
On the security side, keep the S3 bucket private and use Origin Access Control (OAC) so only CloudFront can read from it. For pay-walled or authenticated content, CloudFront signed URLs or signed cookies let you restrict access to specific viewers.
Playing HLS in the Browser with hls.js
Safari and iOS support HLS natively through the <video> element. Chrome and Firefox require hls.js. The component below handles both cases:
"use client";
import { useEffect, useRef } from "react";
import Hls from "hls.js";
interface HlsPlayerProps {
src: string;
poster?: string;
}
export function HlsPlayer({ src, poster }: HlsPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
// Safari has native HLS support — no hls.js needed
if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = src;
return;
}
if (!Hls.isSupported()) {
console.error("HLS is not supported in this browser");
return;
}
const hls = new Hls({
maxBufferLength: 30,
maxMaxBufferLength: 600,
});
hls.loadSource(src);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
video.play().catch(() => {
// Autoplay may be blocked by browser policy unless the video is muted
});
});
hls.on(Hls.Events.ERROR, (_, data) => {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
hls.startLoad(); // Retry on network errors
break;
case Hls.ErrorTypes.MEDIA_ERROR:
hls.recoverMediaError(); // Attempt recovery on decode errors
break;
default:
hls.destroy(); // Unrecoverable error — clean up
}
}
});
return () => {
hls.destroy();
};
}, [src]);
return (
<video
ref={videoRef}
controls
poster={poster}
style={{ width: "100%", aspectRatio: "16/9" }}
/>
);
}
Install hls.js with npm install hls.js. Recent versions ship their own TypeScript types, so a separate @types/hls.js package is usually not needed.
Use the component like this:
// app/video/page.tsx
import { HlsPlayer } from "@/components/HlsPlayer";
export default function VideoPage() {
return (
<main>
<h1>Sample Video</h1>
<HlsPlayer
src="https://cdn.your-site.com/videos/sample/master.m3u8"
poster="https://cdn.your-site.com/videos/sample/thumbnail.jpg"
/>
</main>
);
}
Troubleshooting
Here are the most common issues and how to fix them.
Segments return 404 or CORS errors
- Verify the S3 bucket CORS rules include the correct
AllowedOrigins - Check the CloudFront response headers policy has
Access-Control-Allow-Originset - Confirm the
.tsfiles were uploaded withContent-Type: video/mp2t— usingapplication/octet-streamcan cause problems with some players
Playback stalls mid-video
- The segment duration may be too long. Try reducing
-hls_timeto 4 seconds - Check whether CDN cache is actually hitting. Look at the CloudFront cache hit rate in CloudWatch metrics
- The low-quality stream bitrate may still be too high for slow connections. Try increasing the CRF for 360p to 30 or higher
Safari cannot play the stream
- Make sure the
.m3u8files are served withContent-Type: application/vnd.apple.mpegurl - Verify there are no S3 redirects interfering. Access the m3u8 URL directly in a browser to check
hls.js throws MANIFEST_LOAD_ERROR
- Double-check the master playlist URL is correct
- Make sure the CORS
AllowedOriginslist includeshttp://localhost:3000for local development (add it as a separate entry alongside the production domain)
Encoding is too slow
- Switch
-presettoultrafastfor faster encoding at the cost of file size - Use GPU encoding:
-c:v h264_nvencfor NVIDIA,-c:v h264_videotoolboxon Mac - Run all three encodes in parallel using bash background jobs:
ffmpeg -i input.mp4 -vf "scale=640:360" ... output/360p/index.m3u8 &
ffmpeg -i input.mp4 -vf "scale=1280:720" ... output/720p/index.m3u8 &
ffmpeg -i input.mp4 -vf "scale=1920:1080" ... output/1080p/index.m3u8 &
wait
echo "All encodes finished"
For a broader introduction to FFmpeg commands, see the FFmpeg usage tutorial.
Wrapping Up
The full HLS streaming pipeline with FFmpeg and a CDN comes down to five steps:
- Encode with
ffmpegusing-hls_time 6to produce segments and a playlist - For ABR, encode three quality levels (360p / 720p / 1080p) and write a master playlist by hand
- Upload to S3 —
no-cachefor.m3u8,max-age=31536000for.ts - Configure CORS on both S3 and CloudFront
- Use hls.js in the browser (with a Safari native fallback)
The setup takes more effort than dropping an MP4 on a server, but the payoff is meaningful: CDN cache efficiency goes up, and viewers on slower connections get a smooth experience instead of constant buffering. If you are putting video into production, starting with HLS and a CDN from day one is worth it.