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.
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.
| Layer | What it is | Example | Who handles it in the browser |
|---|---|---|---|
| Pixels | Uncompressed picture from a canvas | RGBA values | Canvas and VideoFrame |
| Codec | Compression method for each stream | H.264 (AVC), VP9, AV1 | VideoEncoder and AudioEncoder |
| Container | Structure that holds chunks, timing and index | MP4, WebM | Nothing built in; a muxing library |
| File | Bytes saved or uploaded | a .mp4 file | Blob 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]
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.
- 1GOP 1 0–1skeyframe, then deltas
- 2GOP 2 1–2skeyframe, then deltas
- 3GOP 3 2–3skeyframe, then deltas
- 4GOP 4 3–4skeyframe, then deltas
- 5GOP 5 4–5skeyframe, then deltas
- 6GOP 6 5–6skeyframe, then deltas
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.
| Browser | First version with full WebCodecs support | Notes |
|---|---|---|
| Chrome | 94 | Full support |
| Edge | 94 | Full support |
| Firefox (desktop) | 130 | Full support on desktop |
| Safari (desktop and iOS) | 26.0 | Partial 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.
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.
Keep reading
FFmpeg.wasm vs WebCodecs vs MediaRecorder: choosing a browser video stack
Compare FFmpeg.wasm, WebCodecs and MediaRecorder for browser video: speed, codec support, file size, frame accuracy and which one to choose for an editor.
Web Audio API for video editors: mixing tracks, drawing waveforms and loudness
Use the Web Audio API in a video editor: an audio graph for live mixing, OfflineAudioContext for export, waveform drawing and loudness normalisation.
More in Browser video editor
Drag, resize and rotate handles for a canvas editor: building the transform gizmo
Build drag, resize and rotate handles for a canvas editor: hit testing, pointer capture, screen-to-canvas coordinates, aspect locks and rotated bounds.
How to test a video renderer: golden images, oracles and property-based tests
Test a browser video renderer properly: golden image comparisons, pixel diff tolerances, independent oracles, property-based tests and their blind spots.
Text and animated captions in a browser video editor: let CSS do the drawing
Render titles and word-by-word captions in a web video editor: canvas text versus HTML and CSS, font loading, timing words to speech and exporting text.