Client-Side Video Transcoding with FFmpeg.wasm in a Vue Creator Dashboard

June 17, 2026

How we eliminated a pre-upload transcoding server by running FFmpeg entirely in the browser using WebAssembly — inside the Kolel creator dashboard.

The Problem

The Kolel creator dashboard (publish.kolel.org) lets educators and rabbis upload video lessons directly from their browser. Creators record on phones, tablets, and cameras — producing files in every format imaginable: .mov.webm.avi.mkv. Our Google Cloud Video Transcoder pipeline expects MP4 input. Running a dedicated server to re-encode every upload before it reaches cloud storage added latency, infrastructure cost, and an extra moving part to maintain.

We needed a way to normalize the format before the file ever left the creator’s machine.

What We Built

We integrated @ffmpeg/ffmpeg into the Vue 2 upload component — making FFmpeg run entirely in the browser via WebAssembly. When a creator picks a non-MP4 file, the browser downloads the FFmpeg WASM core (~30 MB, cached after first load), transcodes the file to MP4 in a Web Worker, and only then uploads the resulting blob to a Google Cloud Storage signed URL. The Rails API and the downstream transcoding pipeline never see anything other than a clean MP4.

How It Works

The full upload sequence:

  1. Creator selects a video file in the Vue component
  2. convertToMp4() checks the MIME type — skips transcoding if already MP4/MOV/M4V
  3. FFmpeg WASM core is loaded (cached in browser after first run)
  4. The source file is written to FFmpeg’s virtual filesystem
  5. ffmpeg.run('-i', input, 'output.mp4') transcodes to MP4
  6. The output bytes are read back and wrapped in a Blob
  7. The component calls the Rails API to get a GCS signed URL
  8. The MP4 blob is PUT directly to GCS — no server in the middle
  9. The Rails API records the video and triggers the Cloud Transcoder job

Key Technical Decisions

1. Loading FFmpeg with a pinned CDN core

import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg';

async convertToMp4(file) {
  const supportedTypes = ['video/mp4', 'video/mov', 'video/m4v'];
  if (supportedTypes.includes(file.type)) {
    return; // already a compatible format, skip transcoding
  }

  const ffmpeg = createFFmpeg({
    log: true,
    corePath: 'https://unpkg.com/@ffmpeg/core@0.10.0/dist/ffmpeg-core.js',
  });

  if (!ffmpeg.isLoaded()) {
    await ffmpeg.load();
  }

  const fileName = file.name;
  const outputFileName = 'output.mp4';

  ffmpeg.FS('writeFile', fileName, await fetchFile(file));
  await ffmpeg.run('-i', fileName, outputFileName);

  const data = ffmpeg.FS('readFile', outputFileName);
  this.selectedFile = new Blob([data.buffer], { type: 'video/mp4' });
}

The WASM binary is large. We pin a specific version of @ffmpeg/core from unpkg so the core is cached by the browser after the first upload session, and creators on a repeat visit pay no load cost:

2. Signed URL upload — no credentials in the browser

async uploadFileToSignedUrl(signedUrl, selectedFile) {
  await axios.put(signedUrl, selectedFile, {
    headers: { 'Content-Type': selectedFile.type },
  });
}

async prepareVideoUpload() {
  this.loading = true;
  // convertToMp4 has already replaced this.selectedFile with the MP4 blob
  await this.getSignedUrl();
  await this.uploadFileToSignedUrl(this.signedUrl, this.selectedFile);

  if (this.pickedThumbnail) {
    await this.getImageSignedUrl();
    await this.uploadFileToSignedUrl(this.thumbnailSignedUrl, this.pickedThumbnail);
  }

  this.addVideo(); // notify Rails API to create the video record
}

The browser never holds GCS credentials. The Rails API generates a short-lived signed URL for the specific file path, then the browser PUTs directly to GCS with the correct Content-Type:

3. Format allow-list instead of always transcoding

Running FFmpeg.wasm on a file that is already MP4 wastes time and memory. We check the MIME type first and bail early for the common formats the transcoder already accepts (video/mp4video/movvideo/m4v). Only exotic formats go through the WASM path.

Results / What Changed

  • Removed the pre-upload transcoding microservice entirely — one less server to maintain
  • Creators uploading common formats (MP4, MOV) see no change in upload speed
  • Exotic formats are handled transparently — creators never see a format error
  • The Rails API and downstream Google Cloud Transcoder always receive well-formed MP4 input
  • No credentials leave the server — the signed URL pattern keeps GCS access tightly scoped

Stack

Vue 2 · @ffmpeg/ffmpeg 0.10 · @ffmpeg/core (WebAssembly) · Google Cloud Storage · GCS Signed URLs · Google Cloud Video Transcoder · Rails 7 API · Axios

Building a video upload pipeline? Get in touch →