Build an HLS Video Transcoder with Node.js and FFmpeg
Learn how to build a production-ready HLS video transcoder in Node.js using FFmpeg — covering adaptive bitrate streaming, segment generation, and serving HLS to any player.
HTTP Live Streaming (HLS) is the de facto standard for adaptive video delivery on the modern web. Netflix, YouTube, and Twitch all use HLS to serve smooth video regardless of a viewer's connection speed. In this tutorial you'll build a Node.js service that accepts a raw video upload and outputs a full HLS stream — the same architecture behind my open-source HLS Transcoder project.
What You Will Build
A Node.js HTTP server that:
- Accepts a video file upload (MP4, MOV, etc.)
- Invokes FFmpeg to produce three adaptive bitrate renditions (1080p, 720p, 360p)
- Produces
.m3u8playlist files and.tssegment files - Serves the HLS output so any compatible player can stream it
By the end you'll have a working transcoder on localhost you can test with VLC or any HLS-capable browser.
Why HLS?
HLS splits a video into small .ts segments (typically 6 seconds each) and generates a playlist file (.m3u8) that describes their order and duration. An adaptive bitrate (ABR) master playlist points to multiple renditions at different qualities. The player automatically picks the best rendition based on the viewer's available bandwidth and switches mid-stream without buffering.
Why build your own instead of using a cloud service? Cost control, offline capability, and full ownership of the pipeline. A cloud transcoder charges per minute of video processed; a self-hosted FFmpeg node on a $6 VPS handles hours of video for cents.
Prerequisites
- Node.js 18+ and npm
- FFmpeg installed on the system (
brew install ffmpegon macOS,sudo apt install ffmpegon Ubuntu) - Basic familiarity with Express
Verify FFmpeg is available:
ffmpeg -version
# ffmpeg version 6.x Copyright (c) 2000-2024 the FFmpeg developersProject Setup
mkdir hls-transcoder && cd hls-transcoder
npm init -y
npm install express multer fluent-ffmpeg
mkdir uploads outputfluent-ffmpeg is a Node.js wrapper around the FFmpeg CLI that gives you a chainable builder API. multer handles multipart/form-data file uploads.
Building the Transcoder
1. Upload Endpoint
// index.js
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const ffmpeg = require('fluent-ffmpeg');
const app = express();
const upload = multer({ dest: 'uploads/' });
app.post('/transcode', upload.single('video'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const jobId = Date.now().toString();
const outputDir = path.join('output', jobId);
fs.mkdirSync(outputDir, { recursive: true });
try {
await transcodeToHLS(req.file.path, outputDir);
res.json({ jobId, playlist: `/stream/${jobId}/master.m3u8` });
} catch (err) {
res.status(500).json({ error: err.message });
}
});2. Adaptive Bitrate Transcoding
The core function runs FFmpeg to produce three renditions in parallel:
function transcodeToHLS(inputPath, outputDir) {
const renditions = [
{ name: '1080p', size: '1920x1080', videoBitrate: '4000k', audioBitrate: '128k' },
{ name: '720p', size: '1280x720', videoBitrate: '2500k', audioBitrate: '128k' },
{ name: '360p', size: '640x360', videoBitrate: '800k', audioBitrate: '96k' },
];
const jobs = renditions.map(r =>
new Promise((resolve, reject) => {
ffmpeg(inputPath)
.videoCodec('libx264')
.videoBitrate(r.videoBitrate)
.audioCodec('aac')
.audioBitrate(r.audioBitrate)
.size(r.size)
.outputOptions([
'-hls_time 6',
'-hls_list_size 0',
`-hls_segment_filename ${path.join(outputDir, `${r.name}_%03d.ts`)}`,
'-f hls',
])
.output(path.join(outputDir, `${r.name}.m3u8`))
.on('end', resolve)
.on('error', reject)
.run();
})
);
return Promise.all(jobs).then(() => writeMasterPlaylist(outputDir, renditions));
}-hls_time 6 sets each segment to six seconds. -hls_list_size 0 keeps all segment entries in the playlist (required for VOD; use a positive number for live streams).
3. Master Playlist
The master playlist lets the player automatically switch renditions:
function writeMasterPlaylist(outputDir, renditions) {
const bandwidth = { '1080p': 4128000, '720p': 2628000, '360p': 896000 };
const resolutions = { '1080p': '1920x1080', '720p': '1280x720', '360p': '640x360' };
const lines = ['#EXTM3U', '#EXT-X-VERSION:3', ''];
renditions.forEach(r => {
lines.push(`#EXT-X-STREAM-INF:BANDWIDTH=${bandwidth[r.name]},RESOLUTION=${resolutions[r.name]}`);
lines.push(`${r.name}.m3u8`);
lines.push('');
});
fs.writeFileSync(path.join(outputDir, 'master.m3u8'), lines.join('\n'));
}4. Serving the HLS Output
app.use('/stream', express.static('output', {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.m3u8')) res.set('Content-Type', 'application/vnd.apple.mpegurl');
if (filePath.endsWith('.ts')) res.set('Content-Type', 'video/mp2t');
},
}));
app.listen(3000, () => console.log('Transcoder running on http://localhost:3000'));Testing
Upload a sample video and get back the playlist URL:
curl -X POST http://localhost:3000/transcode \
-F "video=@sample.mp4" | jq .
# { "jobId": "1746403200000", "playlist": "/stream/1746403200000/master.m3u8" }Open http://localhost:3000/stream/<jobId>/master.m3u8 in VLC (File → Open Network) or Safari. To test in a browser with hls.js:
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<video id="player" controls></video>
<script>
const video = document.getElementById('player');
if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource('http://localhost:3000/stream/JOB_ID/master.m3u8');
hls.attachMedia(video);
}
</script>Production Considerations
Queue long jobs. FFmpeg is CPU-bound and blocks the event loop. In production, push jobs to a BullMQ queue and run transcoder workers as separate processes. This is exactly what the open-source HLS Transcoder does.
Write segments to object storage. Local disk doesn't scale horizontally. Pipe FFmpeg output to S3 or Cloudflare R2, then return pre-signed playback URLs. This keeps the transcoder stateless and allows multiple instances behind a load balancer.
Add CORS headers. If your video player lives on a different origin, add cors() middleware and ensure .m3u8 and .ts responses include the correct Access-Control-Allow-Origin header.
Containerize with FFmpeg baked in. A Dockerfile that installs FFmpeg at build time (apt-get install -y ffmpeg) makes the service portable across environments without runtime surprises.
What's Next
The full production implementation — with S3 integration, a job-status polling API, and a React/hls.js front-end — lives in the HLS Transcoder repository on GitHub. Clone it as a starting point or reference implementation.
If you're building a video platform, live-streaming product, or media processing pipeline and need an experienced full-stack engineer, I'm available for freelance and contract work.