Implementing HLS and DASH Video Streaming
You launch a video service, and after uploading the first videos, you realize — the player stutters, buffering never ends, and mobile users see nothing but a spinner. This is a familiar situation—we've encountered it many times. Our experience: 7+ years in video streaming. The solution is adaptive streaming with proper segmentation, CDN integration, and multi-resolution support. In this article, we'll explain how we implement an HLS/DASH pipeline turnkey, and the pitfalls we encountered.
HLS vs DASH: Which One to Choose?
HLS (HTTP Live Streaming) is an Apple standard, supported by all browsers via hls.js (except Safari, which has native support). DASH (Dynamic Adaptive Streaming over HTTP) is an open standard used for DRM compatibility. In practice, HLS is the default choice: lower latency, wider CDN support. DASH is needed when multiple DRM systems (Widevine, PlayReady) are required.
In our projects, we use HLS as the baseline and add DASH on demand. Comparison in the table:
| Characteristic | HLS | DASH |
|---|---|---|
| Support | All browsers (hls.js for non-Safari) | All browsers (dash.js) |
| Container | MPEG-TS or fMP4 | fMP4 |
| DRM | FairPlay (Apple), Widevine | Widevine, PlayReady |
| Latency | 6–30 sec (LL-HLS: <2 sec) | 2–10 sec |
| CDN compatibility | Excellent | Excellent |
In our tests, HLS showed 2 times lower latency than DASH.
Why FFmpeg Is Not the Only Option?
For video transcoding, we use FFmpeg in conjunction with queues (Laravel Horizon), but for high volumes we incorporate AWS MediaConvert. Let's break down both approaches.
FFmpeg + Queue (Self-hosted)
The transcode-hls.sh script cuts video into three qualities: 1080p, 720p, 360p. Segments of 6 seconds (fMP4). Output: master.m3u8 and sub-playlists.
View full transcoding script
```bash #!/bin/bash # transcode-hls.shINPUT="$1" OUTPUT_DIR="$2"
mkdir -p "$OUTPUT_DIR"
ffmpeg -i "$INPUT"
-filter_complex
"[0:v]split=3[v1][v2][v3];
[v1]scale=1920:1080:force_original_aspect_ratio=decrease[v1080];
[v2]scale=1280:720:force_original_aspect_ratio=decrease[v720];
[v3]scale=640:360:force_original_aspect_ratio=decrease[v360]"
-map "[v1080]" -map 0:a -c:v:0 libx264 -crf 22 -preset fast
-b:v:0 5000k -maxrate:v:0 5500k -bufsize:v:0 10000k
-c:a:0 aac -b:a:0 192k
-map "[v720]" -map 0:a -c:v:1 libx264 -crf 23 -preset fast
-b:v:1 2500k -maxrate:v:1 2750k -bufsize:v:1 5000k
-c:a:1 aac -b:a:1 128k
-map "[v360]" -map 0:a -c:v:2 libx264 -crf 24 -preset fast
-b:v:2 800k -maxrate:v:2 880k -bufsize:v:2 1600k
-c:a:2 aac -b:a:2 96k
-f hls
-hls_time 6
-hls_playlist_type vod
-hls_flags independent_segments
-hls_segment_type fmp4
-hls_segment_filename "$OUTPUT_DIR/v%v/seg%06d.m4s"
-master_pl_name master.m3u8
-var_stream_map "v:0,a:0,name:1080p v:1,a:1,name:720p v:2,a:2,name:360p"
"$OUTPUT_DIR/v%v/index.m3u8"
</details>
Result:
output/ ├── master.m3u8 ├── 1080p/ │ ├── index.m3u8 │ ├── seg000001.m4s │ └── ... ├── 720p/ │ └── ... └── 360p/ └── ...
#### PHP Job for Queue
The TranscodeToHlsJob class is a typical Laravel job. Timeout 2 hours, two attempts. We only handle the successful case.
```php
class TranscodeToHlsJob implements ShouldQueue
{
public int $timeout = 7200;
public int $tries = 2;
public function __construct(private Video $video) {}
public function handle(): void
{
$this->video->update(['status' => 'transcoding']);
$inputUrl = Storage::disk('s3')->temporaryUrl($this->video->original_key, now()->addHours(3));
$outputDir = sys_get_temp_dir() . '/hls_' . $this->video->id;
$s3Prefix = "hls/{$this->video->user_id}/{$this->video->id}";
mkdir($outputDir, 0777, true);
$process = new \Symfony\Component\Process\Process([
'/usr/local/bin/transcode-hls.sh', $inputUrl, $outputDir
]);
$process->setTimeout(7200);
$process->run();
if (!$process->isSuccessful()) {
throw new \RuntimeException('HLS transcoding failed: ' . $process->getErrorOutput());
}
// Upload all files to S3
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($outputDir)
);
foreach ($iterator as $file) {
if (!$file->isFile()) continue;
$relativePath = str_replace($outputDir . '/', '', $file->getPathname());
$s3Key = "{$s3Prefix}/{$relativePath}";
$contentType = str_ends_with($file->getFilename(), '.m3u8')
? 'application/x-mpegURL'
: 'video/mp4';
Storage::disk('s3')->put($s3Key, file_get_contents($file->getPathname()), [
'ContentType' => $contentType,
'CacheControl' => str_ends_with($file->getFilename(), '.m3u8')
? 'no-cache' // playlist not cached
: 'public, max-age=31536000', // segments cached forever
]);
}
$this->video->update([
'status' => 'ready',
'hls_manifest' => "{$s3Prefix}/master.m3u8",
]);
// Clean up temporary files
exec("rm -rf {$outputDir}");
}
}
AWS MediaConvert (Managed)
For high-load projects, we use MediaConvert. Configuration in Python:
def create_hls_job(input_key: str, output_prefix: str) -> str:
client = boto3.client('mediaconvert', endpoint_url=MEDIACONVERT_ENDPOINT)
job = client.create_job(
Role=MEDIACONVERT_ROLE,
Settings={
'Inputs': [{'FileInput': f's3://bucket/{input_key}', 'VideoSelector': {}, 'AudioSelectors': {'Audio 1': {'DefaultSelection': 'DEFAULT'}}}],
'OutputGroups': [{
'Name': 'Apple HLS',
'OutputGroupSettings': {
'Type': 'HLS_GROUP_SETTINGS',
'HlsGroupSettings': {
'Destination': f's3://bucket/{output_prefix}/',
'SegmentLength': 6,
'MinSegmentLength': 0,
'DirectoryStructure': 'SUBDIRECTORY_PER_STREAM',
},
},
'Outputs': [
{'NameModifier': '_1080p', 'VideoDescription': {'Width': 1920, 'Height': 1080, 'CodecSettings': {'Codec': 'H_264', 'H264Settings': {'Bitrate': 5000000, 'RateControlMode': 'CBR', 'GopSize': 90}}}, 'AudioDescriptions': [{'CodecSettings': {'Codec': 'AAC', 'AacSettings': {'Bitrate': 192000}}}]},
{'NameModifier': '_720p', 'VideoDescription': {'Width': 1280, 'Height': 720, 'CodecSettings': {'Codec': 'H_264', 'H264Settings': {'Bitrate': 2500000, 'RateControlMode': 'CBR', 'GopSize': 90}}}, 'AudioDescriptions': [{'CodecSettings': {'Codec': 'AAC', 'AacSettings': {'Bitrate': 128000}}}]},
{'NameModifier': '_360p', 'VideoDescription': {'Width': 640, 'Height': 360, 'CodecSettings': {'Codec': 'H_264', 'H264Settings': {'Bitrate': 800000, 'RateControlMode': 'CBR', 'GopSize': 90}}}, 'AudioDescriptions': [{'CodecSettings': {'Codec': 'AAC', 'AacSettings': {'Bitrate': 96000}}}]},
],
}],
}
)
return job['Job']['Id']
MediaConvert generates HLS playlists on its own. We pay only for transcoding minutes.
CloudFront CDN
To ensure fast video delivery worldwide, we configure CloudFront with two cache policies: for playlists (TTL 5 seconds) and segments (TTL 1 year).
resource "aws_cloudfront_distribution" "video" {
origin {
domain_name = aws_s3_bucket.videos.bucket_regional_domain_name
origin_id = "s3-videos"
origin_access_control_id = aws_cloudfront_origin_access_control.default.id
}
enabled = true
is_ipv6_enabled = true
default_cache_behavior {
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "s3-videos"
viewer_protocol_policy = "redirect-to-https"
forwarded_values {
query_string = false
cookies { forward = "none" }
headers = ["Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"]
}
min_ttl = 0
default_ttl = 86400
max_ttl = 31536000
}
# Playlists .m3u8 — short cache
ordered_cache_behavior {
path_pattern = "*.m3u8"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "s3-videos"
viewer_protocol_policy = "redirect-to-https"
forwarded_values {
query_string = false
cookies { forward = "none" }
}
min_ttl = 0
default_ttl = 5
max_ttl = 30
}
}
React Player with hls.js
Client-side integration: hls.js for modern browsers, fallback to native HLS in Safari.
import Hls from 'hls.js';
import { useEffect, useRef } from 'react';
interface VideoPlayerProps {
manifestUrl: string;
poster?: string;
}
export function VideoPlayer({ manifestUrl, poster }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (Hls.isSupported()) {
const hls = new Hls({
maxBufferLength: 30,
maxMaxBufferLength: 60,
lowLatencyMode: false,
});
hls.loadSource(manifestUrl);
hls.attachMedia(video);
hls.on(Hls.Events.ERROR, (_, data) => {
if (data.fatal) {
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
hls.startLoad();
} else {
hls.destroy();
}
}
});
return () => hls.destroy();
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari: native HLS support
video.src = manifestUrl;
}
}, [manifestUrl]);
return (
<video
ref={videoRef}
poster={poster}
controls
playsInline
style={{ width: '100%', maxHeight: '80vh' }}
/>
);
}
What's Included in the Work
We handle the complete cycle for implementing HLS/DASH streaming on your website:
- Transcoding video into three qualities (1080p, 720p, 360p) using FFmpeg or AWS MediaConvert.
- Integration with queues (Laravel Horizon) for async processing.
- Development of a custom React player based on hls.js with UI customization support.
- Setting up CDN (CloudFront or Cloudflare) for fast content delivery.
- Detailed documentation of the pipeline architecture.
- Training your team on the system.
- 2 weeks of post-launch support.
Typical Mistakes
- Incorrect bitrates: too low for 1080p, too high for 360p. We use Apple recommendations.
- Playlist caching: if master.m3u8 is cached, the player won't see new qualities after retranscoding. We set no-cache.
- Missing CORS: browser blocks segments. We configure CORS on S3/CloudFront.
- Hydration mismatch: if the player renders on the server, it may mismatch with client. We use dynamic import.
Process of Work
- Analysis — study source videos, DRM requirements, target audience.
- Design — select stack (self-hosted FFmpeg or MediaConvert), determine bitrates, CDN.
- Implementation — write transcoder, job, player.
- Testing — check LCP, CLS, INP, Core Web Vitals.
- Deployment — deploy to production, monitor logs.
Timeline and Cost
| Step | Timeline |
|---|---|
| FFmpeg HLS transcoding in queue | 4–5 days |
| AWS MediaConvert integration | 2–3 days |
| hls.js player with React | 1–2 days |
| CloudFront CDN delivery | 1–2 days |
| Full pipeline (upload → transcode → player) | 7–14 days |
Cost is calculated individually based on video volume, bitrates, and DRM needs. Contact us for an assessment of your project — we'll calculate the timeline and cost. Order HLS/DASH streaming implementation and get a consultation. We guarantee your video will work lag-free on any device.







