Introduction
FFmpeg.wasm is a pure WebAssembly and JavaScript port of FFmpeg that runs the full transcoding stack inside a browser tab. A 31 MB WASM module carries libavcodec, libavformat, libavfilter, libswscale, and libswresample, so the same command line arguments that work on a server (-i input.mp4 -c:v libvpx-vp9 output.webm) run client-side. The project is MIT licensed and ships under @ffmpeg/ffmpeg and @ffmpeg/util on npm.
Running FFmpeg in the browser unlocks browser-based video editors (trimmers, meme generators, clip croppers) that eliminate server costs because every cut and export happens locally, clip uploaders that transcode phone recordings to H.264 before upload to halve bandwidth, and format converters that work offline as PWAs because files never leave the device.
This tutorial targets @ffmpeg/ffmpeg 0.12.15 and assumes a modern browser with SharedArrayBuffer enabled through cross-origin isolation headers.
Installation and Setup
Install the main package and the helper utilities. The @ffmpeg/util package ships toBlobURL and fetchFile for CORS-friendly core loading and file-to-Uint8Array conversion.
npm install @ffmpeg/ffmpeg @ffmpeg/util
# or
yarn add @ffmpeg/ffmpeg @ffmpeg/utilFFmpeg.wasm cannot be imported from a CDN such as jsdelivr because the package spawns a Web Worker that must share an origin with the host page under cross-origin isolation. Bundle through webpack, Vite, Rollup, or Next.js and serve the worker from your own domain.
The multi-thread build depends on SharedArrayBuffer. Two response headers are mandatory on every page that loads FFmpeg.wasm:
# Required for SharedArrayBuffer (multi-thread core)
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpSet these through next.config.js headers in Next.js, res.setHeader middleware in Express, or a _headers file on Netlify, Vercel, or Cloudflare Pages. Without both headers, crossOriginIsolated reports false and the multi-thread core refuses to initialize.
Core Features
1. Loading FFmpeg Core (Single-thread vs Multi-thread)
The FFmpeg class spawns a Web Worker and loads the WASM core on demand. The single-thread core (@ffmpeg/core, ~31 MB) works everywhere. The multi-thread core (@ffmpeg/core-mt, ~32 MB) requires SharedArrayBuffer and runs ~2x faster.
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { toBlobURL } from '@ffmpeg/util';
const ffmpeg = new FFmpeg();
// Attach log listener before load
ffmpeg.on('log', ({ message }) => {
console.log(message);
});
// Single-thread core (works without COOP/COEP)
const baseURL = 'https://unpkg.com/@ffmpeg/[email protected]/dist/umd';
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
});
console.log('ffmpeg loaded, single-thread');For multi-thread, point to @ffmpeg/core-mt and supply a worker blob URL. The browser must report window.crossOriginIsolated === true.
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { toBlobURL } from '@ffmpeg/util';
if (!window.crossOriginIsolated) {
throw new Error('COOP/COEP headers missing, multi-thread unavailable');
}
const ffmpeg = new FFmpeg();
const baseURL = 'https://unpkg.com/@ffmpeg/[email protected]/dist/umd';
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
workerURL: await toBlobURL(`${baseURL}/ffmpeg-core.worker.js`, 'text/javascript'),
});toBlobURL fetches each core asset, wraps it in a same-origin blob URL, and hands it to the worker. Cache the blob URLs in IndexedDB for subsequent visits when offline support matters.
2. Video Format Conversion (MP4 to WebM)
The basic flow writes a file into the WASM virtual filesystem, runs an FFmpeg command, and reads the output. fetchFile accepts File, Blob, URL, or Uint8Array.
import { fetchFile } from '@ffmpeg/util';
async function convertToWebm(inputFile) {
await ffmpeg.writeFile('input.mp4', await fetchFile(inputFile));
await ffmpeg.exec([
'-i', 'input.mp4',
'-c:v', 'libvpx-vp9',
'-crf', '30',
'-b:v', '0',
'-c:a', 'libopus',
'output.webm'
]);
const data = await ffmpeg.readFile('output.webm');
const blob = new Blob([data], { type: 'video/webm' });
return URL.createObjectURL(blob);
}
// Usage
const input = document.querySelector('input[type=file]');
input.addEventListener('change', async (e) => {
const url = await convertToWebm(e.target.files[0]);
document.querySelector('video').src = url;
});CRF 30 produces a reasonable VP9 quality-to-size ratio; lower values (18-23) keep higher quality. Use -c:v libx264 for H.264 or -c:v libvpx for VP8. Always call ffmpeg.deleteFile('input.mp4') after reading the output to free memory.
3. Trimming and Cutting Video
Stream copy (-c copy) is nearly instant but can only cut at keyframes. Re-encoding produces frame-accurate cuts at the cost of CPU time.
// Fast keyframe-aligned trim (stream copy)
await ffmpeg.writeFile('input.mp4', await fetchFile(inputFile));
await ffmpeg.exec([
'-ss', '00:00:10', // start at 10 seconds
'-i', 'input.mp4',
'-t', '00:00:05', // duration 5 seconds
'-c', 'copy',
'clip.mp4'
]);
// Frame-accurate trim (re-encode)
await ffmpeg.exec([
'-i', 'input.mp4',
'-ss', '00:00:10.500', // start at 10.5 seconds
'-to', '00:00:15.250', // end at 15.25 seconds
'-c:v', 'libx264',
'-c:a', 'aac',
'precise-clip.mp4'
]);Place -ss before -i for fast seek (approximate) or after -i for slow seek (exact). The flag -t specifies duration relative to -ss, while -to is absolute.
4. Extracting Audio from Video
Strip audio with the -vn flag plus an audio codec: libmp3lame for MP3, pcm_s16le for WAV, or aac for AAC.
// Extract to MP3
await ffmpeg.writeFile('input.mp4', await fetchFile(videoFile));
await ffmpeg.exec([
'-i', 'input.mp4',
'-vn', // discard video stream
'-acodec', 'libmp3lame',
'-ab', '192k', // audio bitrate
'audio.mp3'
]);
const mp3Data = await ffmpeg.readFile('audio.mp3');
const mp3Blob = new Blob([mp3Data], { type: 'audio/mpeg' });
// Extract to WAV (lossless, larger file)
await ffmpeg.exec([
'-i', 'input.mp4',
'-vn',
'-acodec', 'pcm_s16le',
'-ar', '44100', // sample rate
'-ac', '2', // stereo
'audio.wav'
]);Use -acodec copy to keep original audio bytes without re-encoding. The output extension must match the source codec (AAC in MP4, Opus in WebM).
5. Frame Extraction as Images
Extract frames as PNG or JPEG using -vframes and -ss, useful for thumbnails and scrubber strips.
// Single thumbnail at the 3-second mark
await ffmpeg.writeFile('input.mp4', await fetchFile(videoFile));
await ffmpeg.exec([
'-ss', '00:00:03',
'-i', 'input.mp4',
'-vframes', '1',
'-q:v', '2', // JPEG quality 2-31 (lower is better)
'thumbnail.jpg'
]);
const jpg = await ffmpeg.readFile('thumbnail.jpg');
document.querySelector('img').src = URL.createObjectURL(
new Blob([jpg], { type: 'image/jpeg' })
);
// Frame every 2 seconds for a scrubber strip
await ffmpeg.exec([
'-i', 'input.mp4',
'-vf', 'fps=0.5,scale=160:-1', // 0.5 fps = 1 frame per 2s
'frame-%03d.jpg'
]);
// Read all extracted frames
const frames = [];
let idx = 1;
while (true) {
const name = `frame-${String(idx).padStart(3, '0')}.jpg`;
try {
const data = await ffmpeg.readFile(name);
frames.push(URL.createObjectURL(new Blob([data], { type: 'image/jpeg' })));
idx++;
} catch {
break;
}
}The %03d pattern produces zero-padded sequential filenames. scale=160:-1 resizes preserving aspect ratio. For animated thumbnails, use -t 3 -vf fps=10 for 10 frames per second over 3 seconds.
6. Video Compression
Compression balances codec, CRF (quality), and preset (speed vs efficiency). H.264 + CRF 23 + medium is a safe web default.
// Balanced compression for web delivery
await ffmpeg.writeFile('input.mov', await fetchFile(videoFile));
await ffmpeg.exec([
'-i', 'input.mov',
'-c:v', 'libx264',
'-crf', '23', // 0-51, 23 is visually lossless-ish
'-preset', 'medium', // ultrafast, fast, medium, slow, veryslow
'-c:a', 'aac',
'-b:a', '128k',
'-movflags', '+faststart', // enable progressive streaming
'compressed.mp4'
]);
// Aggressive compression for upload-bandwidth-limited users
await ffmpeg.exec([
'-i', 'input.mov',
'-c:v', 'libx264',
'-crf', '28',
'-preset', 'slow',
'-vf', 'scale=1280:-2', // cap width at 1280, -2 keeps even height
'-c:a', 'aac',
'-b:a', '96k',
'compact.mp4'
]);
const data = await ffmpeg.readFile('compressed.mp4');
console.log('output size:', data.length, 'bytes');+faststart moves the moov atom to the start for progressive playback. The -2 in scale=1280:-2 keeps even height (H.264 requirement). Two-pass encoding doubles processing time and is rarely worth it in the browser.
7. Progress Logging
FFmpeg.wasm emits log (stderr lines) and progress (0-1 ratio plus transcoded time) events. Listen before calling exec.
const progressBar = document.querySelector('#progress');
const statusLabel = document.querySelector('#status');
ffmpeg.on('log', ({ type, message }) => {
// type: 'stderr' | 'stdout' | 'info'
console.log(`[${type}] ${message}`);
});
ffmpeg.on('progress', ({ progress, time }) => {
// progress: 0-1 ratio, time: microseconds of processed video
progressBar.value = Math.round(progress * 100);
statusLabel.textContent = `${Math.round(progress * 100)}% (${(time / 1_000_000).toFixed(1)}s processed)`;
});
await ffmpeg.exec(['-i', 'input.mp4', '-c:v', 'libx264', 'output.mp4']);
statusLabel.textContent = 'done';Progress requires FFmpeg to know the input duration. Stream-copy or malformed files may report 0; fall back to a spinner. Remove listeners with ffmpeg.off('progress', handler) to prevent memory leaks on component unmount.
Common Pitfalls
SharedArrayBuffer is not defined: The multi-thread core throws this when COOP/COEP headers are missing. Check window.crossOriginIsolated early and fall back to the single-thread core when isolation is unavailable.
Third-party script blocking under COEP: require-corp breaks any cross-origin resource without Cross-Origin-Resource-Policy: cross-origin. Google Fonts, YouTube embeds, and analytics scripts need a crossorigin attribute or must be proxied. Audit in DevTools before shipping.
WASM bundle size (31-32 MB): Lazy-load FFmpeg only when the user triggers a conversion, show a progress bar during the initial fetch, and cache blob URLs in IndexedDB. Mobile users on 4G will wait 10-20 seconds for the core.
Memory limits: 32-bit WASM caps linear memory at 4 GB, and Chrome kills tabs above ~2 GB. Process large files in segments, call deleteFile after every readFile, and warn when uploads exceed ~500 MB.
Core version mismatch: Pin the core version to match ffmpeg.wasm. @ffmpeg/[email protected] expects @ffmpeg/[email protected]. Using @latest can produce cryptic worker initialization errors.
File cleanup: The virtual filesystem persists between exec calls. Always call ffmpeg.deleteFile for inputs after reading output, or call ffmpeg.terminate() on navigation.
Alternatives Comparison
FFmpeg.wasm vs MediaRecorder API: MediaRecorder captures live streams (webcam, microphone, canvas) natively with zero extra kilobytes. It cannot transcode existing files, trim clips, or extract frames. Use MediaRecorder to capture, FFmpeg.wasm to post-process.
FFmpeg.wasm vs server-side FFmpeg: Server-side FFmpeg has unlimited CPU/memory and handles arbitrarily large files but costs infrastructure and upload bandwidth. FFmpeg.wasm wins on cost and privacy since media never leaves the device. Hybrid architectures (client trim + server final encode) combine both.
FFmpeg.wasm vs WebCodecs API: WebCodecs exposes hardware-accelerated codecs directly to JavaScript, running 10-50x faster via GPU. However, it is lower-level: muxing, framing, and format logic must be written manually. Use WebCodecs for real-time encoding, FFmpeg.wasm for one-off conversion where developer speed matters.
References
The GitHub repository ships example directories for React, Vue, SolidStart/Vite, SvelteKit, and vanilla HTML. The documentation site at ffmpegwasm.netlify.app covers 0.11.x to 0.12+ migration and an API reference for FFmpeg class methods and events.