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.
- Local feedback – Users can preview results without uploading the video 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:
- Includes FFmpeg filters and codecs compiled into the selected core build.
- Offers single-threaded and multithreaded cores.
- The JavaScript wrapper is MIT-licensed; the bundled core and codecs have their own licenses.
Install and initialize
npm install @ffmpeg/ffmpeg@0.12.15 @ffmpeg/util@0.12.2
// Use a browser bundler that supports the package's module worker.
import { FFmpeg } from '@ffmpeg/ffmpeg'
import { fetchFile, toBlobURL } from '@ffmpeg/util'
const ffmpeg = new FFmpeg()
let loading
let busy = false
export async function loadFFmpeg() {
if (ffmpeg.loaded) return
if (!loading) {
loading = (async () => {
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.10/dist/esm'
const coreURL = await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript')
try {
const wasmURL = await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm')
try {
await ffmpeg.load({ coreURL, wasmURL })
} finally {
URL.revokeObjectURL(wasmURL)
}
} finally {
URL.revokeObjectURL(coreURL)
}
})().finally(() => {
loading = undefined
})
}
await loading
}
Call loadFFmpeg() lazily when the user opens an upload dialog. This loads the single-threaded
core, which is tens of megabytes. The wrapper already runs FFmpeg inside a web worker. Serve the
application over HTTPS or localhost, and ensure its content security policy permits the workers,
Wasm, and asset URLs you use. See the official
loading examples.
Satisfy browser requirements
The single-threaded @ffmpeg/core used above needs WebAssembly and worker support; it does not
require SharedArrayBuffer. If you explicitly switch to @ffmpeg/core-mt, provide its additional
worker asset and enable cross-origin isolation for WebAssembly threads:
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
This example produces a PNG thumbnail and releases its temporary virtual files after each call:
export async function extractThumbnail(videoFile, time = '00:00:01') {
if (busy) throw new Error('A thumbnail operation is already running')
busy = true
const id = crypto.randomUUID()
const input = `input-${id}.mp4`
const output = `thumb-${id}.png`
try {
await loadFFmpeg()
await ffmpeg.writeFile(input, await fetchFile(videoFile))
const code = await ffmpeg.exec([
'-ss',
time,
'-i',
input,
'-map',
'0:v:0',
'-frames:v',
'1',
output,
])
if (code !== 0) throw new Error(`FFmpeg failed with exit code ${code}`)
// Seeking past the end may return success without creating an image.
const data = await ffmpeg.readFile(output)
if (!(data instanceof Uint8Array) || data.length === 0) {
throw new Error('No thumbnail was produced')
}
return new Blob([data], { type: 'image/png' })
} finally {
await Promise.allSettled([ffmpeg.deleteFile(input), ffmpeg.deleteFile(output)])
busy = false
}
}
Usage example with error handling:
try {
const blob = await extractThumbnail(file, '00:00:05')
const url = URL.createObjectURL(blob)
thumbnailImg.onload = thumbnailImg.onerror = () => URL.revokeObjectURL(url)
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 sequentially
Reuse the same FFmpeg instance without reloading Wasm. This simple helper awaits each extraction; it writes and removes the input for each timestamp. Disable overlapping actions while it runs:
export async function extractThumbnails(videoFile, marks = ['00:00:01', '00:00:05']) {
const thumbnails = []
for (const mark of marks) thumbnails.push(await extractThumbnail(videoFile, mark))
return thumbnails
}
Keep the UI responsive with a web worker
@ffmpeg/ffmpeg creates its own worker, so an additional wrapper worker is unnecessary for this
example. Await its asynchronous methods, disable repeated clicks during extraction, and show a
loading state while the core downloads or processes a file. Worker execution keeps the UI thread
available, but CPU and memory pressure can still affect responsiveness.
Performance tips
- Asset caching – Cache the versioned core assets to reduce repeat download time.
- 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 – If the selected core cannot load or a file exceeds device limits, offer server-side processing with the user’s agreement to upload the video.
Browser versus server processing
| Aspect | Browser (FFmpeg.wasm) | Server (e.g., Transloadit) |
|---|---|---|
| Latency | Depends on download and device speed | 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
SharedArrayBufferis unavailable with the threaded core – Double-check yourCross-Origin-Embedder-Policy: require-corpandCross-Origin-Opener-Policy: same-originheaders. 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.wasmcan 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. - Mobile browsers – Test core loading and representative files on your supported devices. Memory limits can differ substantially; do not use browser-name detection as a substitute for testing the required features.
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.
