To stream HLS video with FFmpeg and a CDN, encode your source file with ffmpeg -i input.mp4 -hls_time 6 -hls_list_size 0 output.m3u8, upload the segments to S3 with aggressive cache headers, and play them in the browser with hls.js. For adaptive bitrate, encode at multiple resolutions and create a master playlist that references each quality level.
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. The protocol is defined in RFC 8216, and with FFmpeg 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
The full list of HLS muxer options is in the FFmpeg formats documentation. 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. As of v1.6, hls.js ships its own TypeScript types, so a separate @types/hls.js package is 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. See the GPU Encoding Guide for details - 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.
FAQ
What is the difference between HLS and DASH?
Both are adaptive bitrate streaming protocols that split video into segments. HLS was developed by Apple and uses .m3u8 playlists with .ts segments. DASH (Dynamic Adaptive Streaming over HTTP) uses .mpd manifests with .m4s segments. HLS has broader native browser support (especially iOS/Safari) and is the more common choice for general-purpose video delivery. DASH offers slightly more flexibility in codec choices but requires a JavaScript player on all platforms.
What segment duration should I use for HLS?
A segment duration of 4–6 seconds works well for most VOD (video-on-demand) content. Shorter segments (2 seconds) allow faster quality switching in ABR but increase the total number of HTTP requests. Longer segments (10 seconds) reduce request overhead but make seeking less precise. The Apple HLS authoring specification recommends a target duration of 6 seconds.
Can I use HLS for live streaming with FFmpeg?
Yes. For live streaming, pipe your input (e.g., from a camera or screen capture) into FFmpeg with -hls_flags delete_segments and a non-zero -hls_list_size (e.g., 5). This keeps only recent segments in the playlist, which is essential to avoid the playlist growing indefinitely. You will also want -hls_allow_cache 0 so players do not cache stale segments.
Do I need CloudFront, or can I use another CDN?
Any CDN that can serve static files works — Cloudflare, Fastly, Akamai, Bunny CDN, etc. CloudFront is shown in this guide because it integrates directly with S3 and supports per-path cache behaviors, which is handy for applying different headers to .m3u8 and .ts files. If you use Cloudflare R2 as your origin, Cloudflare's built-in CDN is a natural choice with zero egress fees.
Why are my HLS segments .ts instead of .mp4?
The .ts (MPEG Transport Stream) format is the default segment format for HLS and has the widest player compatibility. FFmpeg also supports fMP4 segments (-hls_segment_type fmp4), which produce .m4s files. fMP4 segments are smaller and support modern codecs like AV1 more naturally, but some older players may not handle them. Stick with .ts unless you have a specific reason to switch.
How much storage does ABR require compared to single-bitrate?
Roughly 2–3x more than a single 1080p encode. A three-rung ladder (360p + 720p + 1080p) stores the full-quality version plus two lower-bitrate copies. The 360p and 720p versions are significantly smaller per second of video, so the total is less than 3x. For a 10-minute 1080p video at ~3 Mbps, expect around 250 MB for a three-rung setup versus ~140 MB for single-bitrate.
Does hls.js work on mobile browsers?
On iOS, Safari plays HLS natively without hls.js. On Android, Chrome supports HLS through hls.js using Media Source Extensions (MSE). The component in this guide detects native support first and only falls back to hls.js when needed, so it works across all modern browsers and devices.
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.
Encoding multiple ABR rungs is CPU-intensive. If you do not want to tie up your local machine, build a dedicated encoding server on a VPS so you can run all three encodes in parallel on a remote box.
Enterprise-grade cloud VPS with global data centers
- 13 data centers (US, EU, Asia, Middle East)
- Starting at $4/month for 1GB RAM — pay-as-you-go
- 30-day free trial available
Related articles:
- How to Receive, Convert, and Stream RTSP Camera Feeds with FFmpeg
- Building a Multi-Camera Surveillance Dashboard with FFmpeg
- The Complete Guide to Video Compression with FFmpeg
- AV1 vs H.265 vs H.264: Which Codec Should You Use?
- Build an FFmpeg Encoding Server on a VPS
- Process Video in the Browser with ffmpeg.wasm
- FFmpeg Commands: A Practical Guide from Basics to Advanced