Extract thumbnails from videos in browsers with ffmpeg.wasm

Video thumbnails are essential for modern web applications—they give users a quick visual preview and help them decide whether to engage with your content. With the rise of powerful WebAssembly (Wasm) tooling, you can now create those thumbnails entirely in the browser, keeping users’ media local while reducing server load.
Why extract thumbnails in the browser?
Extracting thumbnails client-side offers several advantages:
- Reduced server load – Encoding work is performed on the user’s device, freeing up back-end resources.
- Immediate feedback – Users see results instantly without a round-trip to your server.
- Improved privacy – Videos never leave the browser, which is especially helpful for sensitive content or when complying with privacy regulations.
Meet ffmpeg.wasm
FFmpeg.wasm is a WebAssembly port of the popular FFmpeg toolkit. It exposes a familiar command-line-like API in JavaScript and runs entirely inside modern browsers.
Key features:
- Builds on FFmpeg 6, so most filters and codecs you know still work.
- Multithreaded thanks to WebAssembly threads.
- MIT-licensed and actively maintained.
Install and initialize
npm install @ffmpeg/ffmpeg@0.12.15 @ffmpeg/util
// app.ts (or .js)
import { FFmpeg } from '@ffmpeg/ffmpeg'
import { fetchFile } from '@ffmpeg/util'
const ffmpeg = new FFmpeg()
export async function loadFFmpeg() {
if (ffmpeg.loaded) return
await ffmpeg.load()
}
Call loadFFmpeg()
once—preferably lazily when the user opens an ‘Upload’ dialog—to avoid adding 25
MiB of Wasm to the initial bundle.
Satisfy browser requirements
FFmpeg.wasm requires the following browser features for optimal performance and functionality:
- SharedArrayBuffer: Essential for multithreading.
- Cross-Origin Isolation: Needed to enable
SharedArrayBuffer
. - WebAssembly: The core technology FFmpeg.wasm is built upon.
- WebAssembly Threads: For parallel processing capabilities.
To enable these, your server must send the following HTTP headers for any page using FFmpeg.wasm:
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
If you are using a service worker or your own CDN, make sure these headers are correctly propagated through every hop.
Extract a single thumbnail
export async function extractThumbnail(videoFile, time = '00:00:01') {
await loadFFmpeg()
// Write the file into FFmpeg’s virtual FS
await ffmpeg.writeFile('input.mp4', await fetchFile(videoFile))
// Run the command: seek, grab one frame, output JPEG
await ffmpeg.exec([
'-i',
'input.mp4',
'-ss',
time, // seek to <time>
'-frames:v',
'1',
'thumb.jpg',
])
// Read the result
const data = await ffmpeg.readFile('thumb.jpg')
return new Blob([data], { type: 'image/jpeg' })
}
Usage example with error handling:
try {
const blob = await extractThumbnail(file, '00:00:05')
const url = URL.createObjectURL(blob)
thumbnailImg.src = url
} catch (err) {
console.error('Thumbnail extraction failed', err)
if (err.message && err.message.includes('load() failed')) {
console.error(
'FFmpeg failed to load. This might be due to browser compatibility or network issues.',
)
} else if (err.message && err.message.includes('SharedArrayBuffer')) {
console.error(
'SharedArrayBuffer is not available. Ensure Cross-Origin Isolation headers are set.',
)
}
}
Grab multiple thumbnails in parallel
You can reuse the same FFmpeg instance to extract several frames without re-loading the Wasm binary:
export async function extractThumbnails(videoFile, marks = ['00:00:01', '00:00:05']) {
await loadFFmpeg()
await ffmpeg.writeFile('input.mp4', await fetchFile(videoFile))
for (const [idx, mark] of marks.entries()) {
await ffmpeg.exec(['-i', 'input.mp4', '-ss', mark, '-frames:v', '1', `thumb_${idx}.jpg`])
}
return Promise.all(
marks.map((_, idx) =>
ffmpeg.readFile(`thumb_${idx}.jpg`).then((data) => new Blob([data], { type: 'image/jpeg' })),
),
)
}
Keep the UI responsive with a web worker
Running FFmpeg on the main thread blocks rendering. Offload the work to a dedicated worker:
// worker.js
import { FFmpeg } from '@ffmpeg/ffmpeg'
import { fetchFile } from '@ffmpeg/util'
const ffmpeg = new FFmpeg()
self.onmessage = async ({ data }) => {
const { file, time } = data
try {
if (!ffmpeg.loaded) await ffmpeg.load()
await ffmpeg.writeFile('in.mp4', await fetchFile(file))
await ffmpeg.exec(['-i', 'in.mp4', '-ss', time, '-frames:v', '1', 'out.jpg'])
const img = await ffmpeg.readFile('out.jpg')
self.postMessage({ ok: true, img })
} catch (e) {
self.postMessage({ ok: false, message: e.message })
}
}
Performance tips
- Service-worker cache – Cache
ffmpeg-core.wasm
(around 25MB) so repeat visits load instantly. - Lazy loading – Import the library and call
loadFFmpeg()
only when the user interacts with a feature that requires it, like selecting a video file. - Limit file size – Large 4K videos may exceed browser memory or take too long to process. Consider capping uploads to a reasonable size, for example, 200 MB.
- Reuse one instance – Creating multiple FFmpeg instances wastes memory and slows down processing.
- Fallback to server – On incompatible browsers (e.g., those without
SharedArrayBuffer
support) or for very large files, enqueue the video for server-side processing instead.
Browser versus server processing
Aspect | Browser (FFmpeg.wasm) | Server (e.g., Transloadit) |
---|---|---|
Latency | Instant for the user’s own video | Round-trip + queue time |
Privacy | Media never leaves the device | Needs upload and storage |
Scalability | Limited by user hardware | Virtually unlimited |
Implementation effort | JS library + headers | API call |
Mobile battery usage | High | Low |
Compatibility | Modern browsers with specific features | Universal |
Use whichever model best fits your product—or combine both for a robust solution.
Troubleshoot common issues
SharedArrayBuffer
is unavailable – Double-check yourCross-Origin-Embedder-Policy: require-corp
andCross-Origin-Opener-Policy: same-origin
headers. Ensure they are correctly applied to the page serving FFmpeg.wasm.RangeError: Out of memory
– The video might be too large or complex for the browser's available memory. Try trimming the video or downscaling it before processing with FFmpeg.wasm, or fall back to server-side processing.- Slow first run – The initial download and compilation of
ffmpeg-core.wasm
can take time. Implement service worker caching and consider a "warm-up" call toloadFFmpeg()
when the page becomes visible or idle, rather than waiting for direct user interaction. - Safari on iOS – As of iOS 17, Safari does not support
SharedArrayBuffer
in Web Workers, which is crucial for FFmpeg.wasm's multithreading. Provide a server-side fallback for these users.
Streamline production with Transloadit
When your app needs to process tens of thousands of videos a day—or must support every browser—our 🤖 /video/thumbs Robot handles thumbnail extraction for you. It supports parallel thumbnail extraction, can generate a customizable count of 1-999 thumbnails per video, allows custom timestamps (using percentage or seconds), offers multiple output formats (JPEG, JPG, PNG), and includes advanced resize strategies such as crop, fit, fillcrop, min_fit, pad, and stretch. Paired with our Video Encoding service, it scales automatically and never blocks the UI.
Next steps
Experiment with FFmpeg.wasm locally, cache the Wasm core for a snappy UX, and decide where the
browser-versus-server trade-off makes sense for your project. If you outgrow client-side limits, an
Assembly that uses the /video/thumbs
Robot is just one API call away.