Skip to main content

VideoEncoder → MP4

Exporting MP4 from the browser: WebCodecs encoding, muxing and server fallback

Export an MP4 from a web video editor: render frames, encode with the WebCodecs VideoEncoder, mux audio and video, and know when to render on a server.

15 min readEngineering

Part 12 of 15Build a browser video editor

VideoEncoder → MP4azelify
Engineering

Exporting MP4 from the browser: WebCodecs encoding, muxing and server fallback

Key takeaways

  • Export is the preview loop run as fast as the encoder allows: render a frame at an exact time, encode it, then move to the next.
  • VideoEncoder only produces compressed chunks; a muxer writes them into an MP4 container alongside the encoded audio track.
  • Check encoder support per device and keep a server render path for machines or settings the browser cannot encode.

Export video from browser JavaScript is the step most people reach after they have built something that plays. The browser already ships the hard parts: a video encoder that turns raw pictures into compressed data, and a canvas that can draw each picture. What is left is packaging, the work of writing those compressed pieces into a file that a phone or a social app will open. This guide walks through that whole path, from a timeline of clips to an MP4 you can post, and it defines each term the first time it appears.

Three ideas carry the rest of the article. An export is the preview loop run as fast as the encoder allows: draw a frame at an exact time, encode it, then move to the next. A video encoder only produces compressed chunks, so a muxer has to write those chunks into an MP4 container, next to an encoded audio track. And not every device can encode every setting, so a dependable export keeps a server path for the cases the browser cannot handle. This post is part of a series on building a browser video editor. The overview of the whole series maps every layer, and the Azelify editor serves as the worked example for each design choice.

Export video from browser JavaScript: what it means#

A timeline is a description. It says which clip plays where, for how long, and with which effects. It is not a video file. A video file is a sequence of pictures, called frames, shown one after another at a steady rate, plus a soundtrack. The rate is the frame rate, usually written as frames per second (fps), so 30 fps means 30 pictures for every second of playback. Each frame has a timestamp, the moment in the video when it should appear, counted from the start.

Exporting therefore means visiting each timestamp in order, drawing the picture the timeline describes at that moment, and recording that picture as data a file can hold. Nothing about the timeline changes during the export. The work is a conversion from a description to pictures, from pictures to a compressed stream, and from that stream to a file.

The most important rule is that export should draw with the same function that draws the preview. If the export uses a second drawing path, the file will drift away from what the person saw on screen, and nobody will know why. In the Azelify editor, export calls the same render routine as preview, at full project resolution, frame by frame. That identity is the main promise the export makes. The rest of this article wraps that one draw call in the machinery a file needs. For the drawing side, read video compositing with canvas and WebGL.

Codec, container and file: three layers#

Three words cause most of the confusion here, so define them with a picture. A codec is a method for compressing pictures or sound into fewer bytes, and for decompressing them again. H.264, also called AVC, is a common video codec, and VP9 and AV1 are others. A container is the file structure that holds one or more compressed streams, together with the timing and index information a player needs. MP4 is a container, formally MPEG-4 Part 14, and it can hold video and audio from several codecs.[1] A file is simply the bytes a container produces, saved to disk or uploaded.

The table puts each layer beside the browser API that handles it. The gap in the last column is the important part.

LayerWhat it isExampleWho handles it in the browser
PixelsUncompressed picture from a canvasRGBA valuesCanvas and VideoFrame
CodecCompression method for each streamH.264 (AVC), VP9, AV1VideoEncoder and AudioEncoder
ContainerStructure that holds chunks, timing and indexMP4, WebMNothing built in; a muxing library
FileBytes saved or uploadeda .mp4 fileBlob or a writable file stream

Read the container row closely. WebCodecs gives you encoders and decoders, but the MDN guide states that container handling is outside its scope, so you need a separate library to write a playable file.[2] Chrome's guide makes the same point from the other side: an application sends encoded chunks over the network or muxes them into a container for storage.[3]

Fig. 01
An export draws, wraps, encodes, packages and saves each frameIllustrative pipeline read left to right. The editor draws a frame at an exact time, wraps the canvas as a VideoFrame, passes it to a VideoEncoder that outputs compressed chunks, packages those chunks into an MP4 container, and finally saves the file as a Blob or a writable stream. The loop repeats for every frame before the last step runs.
Draw framesame draw as preview
Wrap frameVideoFrame at time t
EncodeVideoEncoder to chunks
Packagechunks to MP4 container
Save fileBlob or writable file
  1. 01 → Draw framesame draw as preview
  2. 02 → Wrap frameVideoFrame at time t
  3. 03 → EncodeVideoEncoder to chunks
  4. 04 → Packagechunks to MP4 container
  5. 05 → Save fileBlob or writable file
An export draws, wraps, encodes, packages and saves each frame

How to export video from browser JavaScript, step by step#

The steps below follow the pipeline in the figure. Each one is small, and each one has a reason. The code is plain JavaScript with TypeScript types, and you can paste the pieces into a module to see them run.

Step 1: compute the frame grid#

Before you draw anything, decide every moment you will visit. Count the frames first, then compute each frame's time from its index. The index is a whole number, and the time is the index divided by the frame rate.

// The frame grid: every export moment is index / fps, never a running sum.
const fps = 30;
const durationSeconds = 12;
const frameCount = Math.round(durationSeconds * fps); // 360 frames

function frameTimeSeconds(index: number, rate: number): number {
  if (!Number.isInteger(index) || index < 0) throw new Error('index must be a whole number');
  if (rate <= 0) throw new Error('frame rate must be positive');
  return index / rate;
}

Why divide instead of adding one frame duration on every pass? Binary floating-point numbers cannot store 1/30 exactly, so adding it ten thousand times accumulates error that can move the last frame by a visible amount. Dividing the integer index by the rate gives each frame its correct time with no memory of earlier frames. Our editor does the same and rejects a non-integer index or a non-positive rate, because a silently wrong grid is hard to spot in one frame and easy to spot in a whole file.

Step 2: draw the picture at that time#

Next, draw the picture for time t onto a canvas. The function takes a 2D context and a time, and it knows nothing about files. Keeping that boundary clean is what lets one function serve both the preview and the export.

// Draw the timeline's picture at time t. The same renderFrame runs in preview.
type RenderFrame = (ctx: CanvasRenderingContext2D, timeSeconds: number) => void;

function drawAt(ctx: CanvasRenderingContext2D, timeSeconds: number, renderFrame: RenderFrame): void {
  const { width, height } = ctx.canvas;
  ctx.clearRect(0, 0, width, height); // never reuse the last frame's pixels by accident
  renderFrame(ctx, timeSeconds);
}

If the drawing runs inside a web worker, the canvas is usually an OffscreenCanvas (opens in a new tab).[4] The shape of the code stays the same.

Step 3: wrap the canvas as a VideoFrame#

A VideoFrame is the browser's handle to one decoded or drawn picture. You create it from a canvas and give it a timestamp. The timestamp is in microseconds, so 30 fps means a step of about 33,333 microseconds. The MDN guide uses the same unit in its encoder example.[2]

// Wrap the drawn canvas as a VideoFrame. Timestamps are in microseconds.
function toVideoFrame(canvas: HTMLCanvasElement, index: number, rate: number): VideoFrame {
  const timestampMicros = Math.round((index * 1_000_000) / rate);
  const durationMicros = Math.round(1_000_000 / rate);
  return new VideoFrame(canvas, { timestamp: timestampMicros, duration: durationMicros });
}

A VideoFrame holds memory the JavaScript garbage collector does not manage for you. Step 5 covers when to release it.

Step 4: check support, then configure the encoder#

Now describe the output you want. The codec field must be a fully specified codec string, not a short name. The MDN example uses vp09.00.40.08.00 for VP9.[2] For H.264, the W3C registry lists the avc1 and avc3 prefixes.[5] The width, height, bitrate and frame rate describe the picture, and the bitrate is the number of bits spent per second of video.

// Describe the output, then ask the browser whether it can produce it.
const config: VideoEncoderConfig = {
  codec: 'vp09.00.40.08.00',
  width: 1280,
  height: 720,
  bitrate: 4_000_000, // 4 million bits per second
  framerate: 30,
  hardwareAcceleration: 'prefer-software',
};

const support = await VideoEncoder.isConfigSupported(config);
if (!support.supported) {
  throw new Error('This browser cannot encode these settings');
}

configure() throws a NotSupportedError for a configuration the browser cannot produce, which is why the check comes first.[3] The hardwareAcceleration field is a hint with three values: no-preference, prefer-hardware and prefer-software. The default is no-preference.[6] Hardware encoders use dedicated silicon and are fast, but their quality and availability vary from one machine to another. Software encoders are slower and more predictable. Our editor asks for software because a file should look the same wherever it was made, and an export has no 16 ms frame budget the way preview does.

Step 5: encode and close each frame#

The encoder reports its output through a callback. Each callback receives one compressed EncodedVideoChunk, along with metadata that can include a decoder configuration. The code below starts the encoder and then feeds it frames, closing each frame as soon as it has been handed over.

// Start an encoder that passes every compressed chunk to a callback.
function startEncoder(config: VideoEncoderConfig, onChunk: EncodedVideoChunkOutputCallback): VideoEncoder {
  const encoder = new VideoEncoder({
    output: onChunk,
    error: (error) => console.error('encode failed', error),
  });
  encoder.configure(config);
  return encoder;
}

// Encode one frame. A keyframe every 30 frames gives a seek point each second at 30 fps.
async function encodeOne(encoder: VideoEncoder, frame: VideoFrame, index: number): Promise<void> {
  encoder.encode(frame, { keyFrame: index % 30 === 0 });
  frame.close(); // release the picture now that the encoder has it
  while (encoder.encodeQueueSize > 8) {
    await new Promise<void>((resolve) =>
      encoder.addEventListener('dequeue', () => resolve(), { once: true }),
    );
  }
}

Two details matter more than they look. The first is that the callback receives chunks in order, and the muxer in the next step must keep that order. The second is the wait loop, which the next section explains. A real exporter does not collect the chunks in an array. It passes each chunk straight to the muxer, which writes it to a file, so memory stays flat no matter how long the video is.

Step 6: package the chunks into an MP4#

There are two honest routes to an MP4. The first is to write the muxer yourself on top of the encoder's chunks. The mp4-muxer project was a popular choice for this, but its README now says it has been deprecated in favor of mediabunny, which supersedes it.[7] Mediabunny is a TypeScript library under the MPL-2.0 licence that reads and writes several containers and can drive the encoder for you.[8]

The second route, the shorter one, lets the library encode the canvas. The code below follows the mediabunny writing guide. Its CanvasSource encodes the canvas, so you skip steps 4 and 5 and add frames by timestamp.

import { Output, Mp4OutputFormat, BufferTarget, CanvasSource, Quality } from 'mediabunny';

// canvas is an HTMLCanvasElement that renderFrame draws onto.
const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
const videoSource = new CanvasSource(canvas, { codec: 'avc', quality: new Quality({ bitrate: 4e6 }) });
output.addVideoTrack(videoSource, { frameRate: 30 });
await output.start();

for (let i = 0; i < frameCount; i++) {
  const t = frameTimeSeconds(i, 30);
  drawAt(canvas.getContext('2d')!, t, renderFrame);
  videoSource.add(t, 1 / 30); // captures the canvas as it is at this call
}

await output.finalize();
const mp4Bytes = output.target.buffer; // a Uint8Array you can save or upload

The writing guide states that add captures the canvas state at the moment of the call, and it calls start before any frames are added and finalize after the last one.[9] Its example calls add without await. Read the guide for how the library handles a backed-up queue before you rely on this loop for a long export.

Audio: encode it before the video#

Sound is a second stream inside the same file. Its samples are measured at a sample rate, the number of audio samples per second, often 48,000. The AudioEncoder (opens in a new tab) works like the video encoder: you give it audio data and it returns encoded chunks.[10] The muxer then writes those chunks as a second track beside the video.

The order in which you run the two encoders can matter. In our editor, the whole soundtrack is encoded and closed before the first video frame is drawn. The reason is loudness. Loudness normalisation has to measure the entire programme before it can set a single gain, so the audio must be complete before the picture work begins. The muxer can interleave a finished audio track with video that arrives later, so the order costs nothing in the file. For how the audio is mixed and measured, read Web Audio API for video editors.

Keyframes and the GOP rhythm#

Video compression does not store every frame in full. A keyframe, also called an I-frame, is coded on its own, so a decoder can start there. The frames after it are delta frames, which store only what changed. A group of pictures, or GOP, is the span from one keyframe to the next, and a keyframe begins each group.[11]

The figure shows six seconds of video at 30 fps, cut into six groups of 30 frames. Each group opens with one keyframe, which matches the keyframe setting in the code above.

Fig. 02
GOP 10–1s
GOP 21–2s
GOP 32–3s
GOP 43–4s
GOP 54–5s
GOP 65–6s
0s5s
1s · Keyframe2s · Keyframe3s · Keyframe
Illustrative: a keyframe opens each one-second group at 30 fpsA six-second timeline split into six equal groups of pictures, each one second long. Every group starts with a keyframe that a decoder can begin from, followed by delta frames that depend on earlier pictures. Markers at the group boundaries show where a seek can land without decoding from further back.123456
  1. 1GOP 1 0–1skeyframe, then deltas
  2. 2GOP 2 1–2skeyframe, then deltas
  3. 3GOP 3 2–3skeyframe, then deltas
  4. 4GOP 4 3–4skeyframe, then deltas
  5. 5GOP 5 4–5skeyframe, then deltas
  6. 6GOP 6 5–6skeyframe, then deltas
Illustrative: a keyframe opens each one-second group at 30 fps
Illustrative, not data[mdn-using][wiki-gop]

The interval is a trade-off. The MDN guide says typical keyframe intervals are once every 30 or 60 frames, because more keyframes make the file larger, while fewer can make playback unstable in some players. It also says the first frame should be a keyframe, even though the encoder forces one.[2] Our code above follows that advice with a keyframe every 30 frames. The consequence for editing is that a seek to a frame in the middle of a group must decode forward from the keyframe before it. The WebCodecs decoding post explains that cost in detail.

Backpressure: the queue that can eat memory#

Backpressure is the rule that a fast producer must wait for a slow consumer. Here the producer is your render loop and the consumer is the encoder. The encoder keeps a count of pending work in encodeQueueSize, and it fires a dequeue event each time that count shrinks. The flush() method returns a promise that resolves once the queued work has finished, which you need before reading the last chunks.[3]

Without the wait loop from step 5, a fast render loop can queue thousands of frames and exhaust memory. That is why the loop pauses whenever the queue grows past a small number.

Browser support: check before you promise#

Support for WebCodecs is good but not universal, and it differs between desktop and mobile. The table below uses the first version with full support, as reported by Can I use at the time of writing.[12] The table is a snapshot, so check the live page before you publish a claim.

BrowserFirst version with full WebCodecs supportNotes
Chrome94Full support
Edge94Full support
Firefox (desktop)130Full support on desktop
Safari (desktop and iOS)26.0Partial support from 16.4 to 18.7

The MDN reference also says that the encoder is available only in secure contexts, which means pages served over HTTPS.[13] A page that works on your laptop can still fail in a context the browser restricts, so test the export on the real domain.

When to render on a server#

If the browser cannot encode the settings, the export has to go somewhere else. A server render runs the same render function inside a headless browser, which is a real browser without a window, and it writes the file on the server. The decision has three branches. If this browser can encode the settings and the job is small, export here. If it can encode them but the job is long or large, a server can help. If it cannot encode them at all, a server is the only route.

The figure states the branches as a decision tree.

Fig. 03
Export in the browser when it can, and send large or unsupported jobs elsewhereA two-level decision tree. The first question asks whether this browser can encode the chosen settings. If not, the outcome is a stop for this browser and the job must go to a server. If it can, the second question asks whether the export is long or large. A long or large job is routed to a server render where one exists, and a short job is exported in the browser.yesyesnono
Can this browser encode the settings?
Is the export long or large?
Server render, if available
Export in this browser
Cannot encode here: use server
  1. Can this browser encode the settings?
    1. yes → Is the export long or large?
      1. yes → Server render, if available
      2. no → Export in this browser
    2. no → Cannot encode here: use server
Export in the browser when it can, and send large or unsupported jobs elsewhere
Illustrative, not data[chrome-webcodecs]

A server render has costs that a browser export does not. The file must be uploaded, the server bills for its time, and the server's browser has its own codec support. The last point is easy to miss. Whatever codecs the headless browser supports are the codecs available to the server path, so check them there too.

How the Azelify editor handles export#

Our editor follows the pattern above, with decisions that are worth knowing because each one has a cost. Export runs the same render routine as preview, as described earlier, so the file matches the picture a person saw.

Export is also a job, not a dialog. In the editor, a sequential queue owns the export, and the export dialog only adds work to it and closes. The queue runs one job at a time on purpose, because two concurrent WebCodecs encodes on consumer hardware finish more slowly than two consecutive ones. The first finished file is usable while the rest still run. If the editor is closed while a browser export is running, the job is cancelled with a visible reason, never left to disappear silently. Cancellation has to reach the work itself, not only the record of the work. A long offline audio render, for example, must be told to stop, or the processor keeps busy after the job is marked cancelled.

Routing is the second decision. The editor chooses between this browser and a server render with an order of rules. Hard incapability comes first, so a browser that cannot produce the file is never asked to try. An explicit choice by the person comes next, and an explicit choice is never silently overridden. Cost comes last, based on the expected duration and size of the output. Auto-routing will not send a project to the server when some of its media exists only in this browser and the person has not agreed to upload it, because a silent upload would be a surprise.

The writer is the third decision. The muxer revisits the file header after the frames are written, to fill in sizes, so the output must support seeking. In the browser the editor writes to a file stream, which means a two-hour export never has to fit in memory. For the in-memory case, the editor stores fixed-size pages instead of growing one contiguous array. Doubling and copying a single array on every write becomes slow and wasteful as the output grows.

Two more choices protect the viewer. Codec selection never rewrites a request silently. If a person chooses AV1 and the browser cannot encode AV1, the export fails with a clear message rather than delivering H.264 and calling it AV1. Audio is the one deliberate exception: its bitrate is a quality knob, so the editor steps down through a ladder of bitrates until the encoder accepts one. The editor also checks the finished file. It probes the real bytes and refuses the export if a track the person asked for is missing, instead of trusting the label on the job.

Determinism is the last concern. The frame times come from the index divided by the rate, as in step 1. The container metadata uses the project's creation date rather than the current clock, so exporting the same project twice gives the same metadata bytes. The editor's goal is identical frames. It does not promise identical bytes, because hardware encoders are not required to produce the same bitstream, and the promise is about the pictures.

The server path taught a lesson about data size. Raw pixels were the expensive part of a server render, and the cost came from moving the pixels between processes, not from drawing them. Now the render page encodes its frames when its browser can, and sends the small encoded chunks onward. A page that cannot encode still falls back to raw pixels. In one measurement, an 18-second vertical reel fell from about 153.5 seconds to about 3.2 seconds after the change.

Frequently asked questions#

How do I export video from browser JavaScript without uploading it?#

Use WebCodecs for the encoding and a muxing library for the MP4 container, and run both in the browser. The file is created on the person's machine, and nothing is uploaded unless you choose to send it. Check the encoder's support first, because a device without it needs a different route.

Why does my exported MP4 have no sound?#

The most common cause is that the audio track was never encoded or never added to the muxer. Confirm that you created an audio encoder, passed its chunks to the muxer, and added an audio track before starting the output. Then probe the finished file to check that it really contains an audio track.

What codec string should I use for H.264?#

Use a fully specified string that begins with the avc1 prefix, which the W3C registry lists for AVC.[5] The rest of the string describes the profile and level. Ask VideoEncoder.isConfigSupported about your exact string before you configure the encoder, because the browser decides what it accepts.

Why is the exported file bigger than the preview?#

Preview and export solve different problems. Preview only has to look good on screen for a short moment, so it can use a low bitrate. An export is a file that will be kept, so its bitrate, keyframe spacing and resolution are chosen for quality. Check the bitrate and the keyframe interval before you assume the encoder is wrong.

How do I cancel a long browser export?#

Give the job an AbortController, and connect its signal to the render loop and to any long await inside it. Check the signal between frames, and close the encoder and any open frames in a finally block. A cancel that only changes a label while the work keeps running is a common bug.

Is WebCodecs enough to write an MP4 by itself?#

No. WebCodecs produces compressed chunks, and the MP4 container is a separate job. The MDN guide says container handling is outside the scope of the API, so you need a muxing library or your own writer.[2]

Where to go next#

If you want the reverse direction, read how to decode video frames with WebCodecs, which covers the chunks that an export produces. If you are deciding between WebCodecs and the other browser video options, read FFmpeg.wasm vs WebCodecs vs MediaRecorder, which puts the same export job side by side with the alternatives.

Sources

  1. Wikipedia, “MP4 file format,” en.wikipedia.org (opens in a new tab) (accessed 2026-10-13). ↩

  2. MDN Web Docs, “Using the WebCodecs API,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩ ↩[2] ↩[3] ↩[4] ↩[5] ↩[6]

  3. Chrome for Developers, “Video processing with WebCodecs,” developer.chrome.com (opens in a new tab) (accessed 2026-10-13). ↩ ↩[2] ↩[3]

  4. MDN Web Docs, “OffscreenCanvas,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  5. W3C, “WebCodecs Codec Registry,” www.w3.org (opens in a new tab) (accessed 2026-10-13). ↩ ↩[2]

  6. MDN Web Docs, “VideoEncoder: configure() method,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  7. Vanilagy, “mp4-muxer,” github.com (opens in a new tab) (accessed 2026-10-13). ↩

  8. Vanilagy, “Mediabunny,” github.com (opens in a new tab) (accessed 2026-10-13). ↩

  9. Mediabunny, “Writing media files,” mediabunny.dev (opens in a new tab) (accessed 2026-10-13). ↩

  10. MDN Web Docs, “AudioEncoder,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  11. Wikipedia, “Group of pictures,” en.wikipedia.org (opens in a new tab) (accessed 2026-10-13). ↩

  12. Can I use, “WebCodecs API,” caniuse.com (opens in a new tab) (accessed 2026-10-13). ↩

  13. MDN Web Docs, “VideoEncoder,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

Build a browser video editor

In this series

  1. 01How to build a video editor in the browser: the complete architecture
  2. 02Designing the project file for a web video editor: schema, versions and autosave
  3. 03Undo and redo in a web editor: the command pattern, explained from zero
  4. 04How a video editor timeline works: tracks, clips, trims and overlaps
  5. 05Importing video files in the browser: probing, codecs, proxies and storage
  6. 06WebCodecs tutorial: decoding video frames in the browser without a video tag
  7. 07Frame-accurate playback in JavaScript: clocks, requestAnimationFrame and A/V sync
  8. 08Compositing video layers with Canvas and WebGL: transforms, blends and effects
  9. 09Text and animated captions in a browser video editor: let CSS do the drawing
  10. 10Drag, resize and rotate handles for a canvas editor: building the transform gizmo
  11. 11Web Audio API for video editors: mixing tracks, drawing waveforms and loudness
  12. 12Exporting MP4 from the browser: WebCodecs encoding, muxing and server fallback
  13. 13Keyframe animation in a web editor: interpolation, easing curves and motion tracking
  14. 14How to test a video renderer: golden images, oracles and property-based tests
  15. 15FFmpeg.wasm vs WebCodecs vs MediaRecorder: choosing a browser video stack

Export a finished vertical video

Render your edit to MP4 and download it, ready to post.

Open the studio

Keep reading

More in Browser video editor

All guides