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 your Cross-Origin-Embedder-Policy: require-corp and Cross-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 to loadFFmpeg() 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.