post://a-browser-media-engine-without-ffmpeg-wasm

A browser media engine without FFmpeg WASM

read: 7 min words: 1,226
A browser media engine without FFmpeg WASM
toc://sections
outline

    Web media players tend to fail in one of two directions. The first is the monolithic player that bundles a 15 to 40 MB FFmpeg WASM binary so it can decode everything. It works on a fast machine and a good connection, and then it meets a mid-range Android phone on a train, where it adds seconds to startup, holds hundreds of megabytes of RAM for the life of the session, and drains the battery doing in software what the operating system would happily do in hardware. The second is the rigid player that hardcodes layout, networking, and UI into one package, so customizing anything means forking the whole thing.

    VidoLib takes a third position: use the browser's own media pipeline, and make every other piece a package you opt into. Repo: swadhinbiswas/VidoLib

    The observation that started it

    Browsers spent a decade building a hardware media stack. WebCodecs exposes the platform decoders. WebAudio and AudioWorklet handle the audio path. WebGL and WebGPU render frames. Between them, a modern browser can play HLS, DASH, MP4, WebM, MKV, FLV, TS, and subtitles without a software decoder in sight.

    An app that ships FFmpeg WASM anyway is paying for capability it already has. The reason most players do it is portability and habit: a WASM decoder behaves the same everywhere, and the platform path has edges you have to learn. The cost is real and shows up exactly where it hurts, on the devices with the least memory.

    What the difference looks like in numbers

    The project keeps a comparison against the WASM approach, and the gaps are large enough to be the whole argument.

    Metric Native VidoLib FFmpeg WASM players
    Engine download 3.4 KB to 30 KB 15 MB to 40 MB
    Startup latency under 110 ms 1,500 ms to 4,000 ms
    Memory footprint under 18 MB 150 MB to 400 MB
    Battery OS hardware decoders CPU-bound WASM threads
    Mobile web native decoder support frequent out-of-memory crashes on iOS Safari

    The last row is the one that ends most arguments. Under WebKit's tighter tab memory limits, a WASM player that works in testing can be evicted on a real iPhone at the worst moment. Hardware decoding sidesteps the failure mode instead of tuning around it.

    The architecture

    A load request moves through a predictable chain, and the layering is what makes the size claims honest.

    flowchart LR
      A[Stream layer<br/>HTTP Range / Blob / WS / WebRTC] --> B[Container parser<br/>MP4 / MKV / WebM / TS ...]
      B --> C[WebCodecs<br/>hardware decoder]
      C --> D[Renderer<br/>Canvas2D / WebGL / WebGPU]
      C --> E[WebAudio<br/>AudioWorklet]
      F[ABR engine] -.-> A
      G[Clock / EventBus] -.-> D
      G -.-> E
      G -.-> F
    

    The stream layer fetches bytes, the container parser demuxes them, the decoder produces frames and audio, and the render and audio sinks present them. A clock and event bus coordinate the pieces, and an adaptive bitrate engine feeds back into the stream layer. Every box in that diagram is a separate package.

    <svg viewBox="0 0 900 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="An audio equalizer bar animation">
      <g fill="#94e2d5">
        <rect x="220" y="120" width="14" height="40" rx="7">
          <animate attributeName="height" values="40;110;60;130;40" dur="1.6s" repeatCount="indefinite"/>
          <animate attributeName="y" values="120;50;100;30;120" dur="1.6s" repeatCount="indefinite"/>
        </rect>
        <rect x="260" y="110" width="14" height="60" rx="7">
          <animate attributeName="height" values="60;40;120;70;60" dur="1.6s" begin="0.15s" repeatCount="indefinite"/>
          <animate attributeName="y" values="110;130;50;100;110" dur="1.6s" begin="0.15s" repeatCount="indefinite"/>
        </rect>
        <rect x="300" y="100" width="14" height="80" rx="7">
          <animate attributeName="height" values="80;130;50;110;80" dur="1.6s" begin="0.3s" repeatCount="indefinite"/>
          <animate attributeName="y" values="100;50;130;70;100" dur="1.6s" begin="0.3s" repeatCount="indefinite"/>
        </rect>
        <rect x="340" y="130" width="14" height="30" rx="7">
          <animate attributeName="height" values="30;100;55;125;30" dur="1.6s" begin="0.45s" repeatCount="indefinite"/>
          <animate attributeName="y" values="130;60;105;35;130" dur="1.6s" begin="0.45s" repeatCount="indefinite"/>
        </rect>
        <rect x="380" y="115" width="14" height="50" rx="7">
          <animate attributeName="height" values="50;115;45;95;50" dur="1.6s" begin="0.6s" repeatCount="indefinite"/>
          <animate attributeName="y" values="115;45;115;65;115" dur="1.6s" begin="0.6s" repeatCount="indefinite"/>
        </rect>
        <rect x="420" y="120" width="14" height="40" rx="7">
          <animate attributeName="height" values="40;90;125;55;40" dur="1.6s" begin="0.75s" repeatCount="indefinite"/>
          <animate attributeName="y" values="120;70;35;105;120" dur="1.6s" begin="0.75s" repeatCount="indefinite"/>
        </rect>
      </g>
      <line x1="160" y1="170" x2="740" y2="170" stroke="currentColor" stroke-opacity="0.3" stroke-width="2"/>
      <text x="620" y="110" font-family="ui-monospace, monospace" font-size="14" fill="currentColor" opacity="0.6">core 3.4 KB</text>
    </svg>
    

    Twenty-two packages and a small core

    The core is 3.44 KB gzipped and bundles no decoders. Everything else is its own package with its own size: the stream layer at 2.83 KB, containers at 14.11 KB, the HLS and DASH manifest parsers at 6.99 KB, the ABR engine at 1.75 KB, codecs at 4.63 KB, the transcoder at 1.50 KB, WebGL filters at 3.34 KB, the recorder at 1.62 KB, renderer backends at 5.36 KB, subtitles at 5.21 KB, a WCAG 2.1 AA UI at 5.49 KB, the worker pipeline at 2.73 KB, a zero-tracking telemetry package at 1.76 KB, a security package at 1.83 KB, and a plugin registry at 1.42 KB.

    That structure is the feature. A player that only needs HLS imports the stream layer, the manifest parser, the codecs negotiator, and a renderer, and lands under 15 KB gzipped. Nothing pulls in the MKV parser, the recorder, or the filter chain unless the app asks for them. Micro-frontend and bundler hygiene are usually an afterthought that gets fought later; here they set the package boundaries from the start.

    The capabilities are the FFmpeg operations web apps actually need, reimplemented natively. A @vidolib/transcoder package encodes frames to H.264 or VP9 through VideoEncoder. A @vidolib/filters package does chroma key, brightness, contrast, blur, and watermarking as GPU passes. A @vidolib/recorder package records canvas or a media stream to MP4 and WebM. A @vidolib/containers registry auto-demuxes across MP4, MKV, WebM, AVI, MOV, FLV, TS, PS, OGG, and ASF. The subtitle engine handles ASS, SRT, and VTT.

    Patent and licensing, handled deliberately

    Hardware decoding has a second benefit that is easy to miss. Licensing for codecs like H.264, HEVC, and AC-3 sits with the codec pool, and an application that decodes those in software can inherit that liability. By decoding exclusively through the operating system and browser hardware paths, VidoLib keeps the question at the platform layer. The project makes the boundary explicit: the MIT license covers the source and does not grant patent rights for any codec the deployment target does not already license.

    Testing a demuxer

    Media parsing is the kind of code that fails on the one file nobody tested. VidoLib runs 118 unit tests, spread across containers, manifest, codecs, subtitles, filters, worker, renderer, core, utils, and UI, plus a fuzz pass of 8,000 iterations across every demuxer. Fuzzing a container parser is not optional if the input comes from the network, because a malformed header is an attack surface as much as a bug.

    Where it is

    VidoLib is pre-release. The packages build from source with esbuild and are not published to npm yet, and the API can change before v1.0.0. The architecture and plugin-authoring docs are in the repository, and a custom plugin is meant to be under thirty lines.

    The broader lesson is one I keep meeting: before shipping a large dependency, check what the platform already does. The browser has a hardware media pipeline that most players ignore in favor of a WASM binary, and using what is already there is faster, smaller, and easier on the device. The hard part is not capability, it is trust in the platform, and the way to earn that trust is to test the edges properly.

    Reach me at swadhinbiswas.cse@gmail.com or on GitHub and LinkedIn.

    react://a-browser-media-engine-without-ffmpeg-wasm
    comments://a-browser-media-engine-without-ffmpeg-wasm

    No comments yet.