ffmpegvideo-encodingwebm

MediaRecorder WebM Isn't Broken. Convert It to MP4 Server Side

·Javid Jamae·10 min read
MediaRecorder WebM Isn't Broken. Convert It to MP4 Server Side

Your app records in the browser, uploads the blob, and the file plays back fine. Then someone drags the scrubber and nothing moves. video.duration returns Infinity, the progress bar never fills, and every downstream step that needs a length either guesses or throws.

Quick answer: A MediaRecorder WebM blob reports duration: Infinity because Chrome writes it as a live stream with no Duration element and no Cues index. To convert MediaRecorder WebM to MP4 and get a seekable file, re-run it through FFmpeg on a server: ffmpeg -i recording.webm -c:v libx264 -preset veryfast -crf 23 -c:a aac -b:a 128k -movflags +faststart recording.mp4. If WebM output is fine, ffmpeg -i recording.webm -c copy -cues_to_front 1 fixed.webm rebuilds the duration and index in seconds with no re-encode. FFmpeg Micro runs either job as one API call, so you skip hosting an encoder and babysitting function timeouts.

Chrome's MediaRecorder writes a live stream, not a finished file

Conventional wisdom says a blob that reports Infinity is corrupt. It isn't. The bitstream is fine, every frame is there, and a linear play-through works. What's missing is container metadata.

WebM is Matroska. A finished Matroska file has a Segment with a known size, a Duration element inside the Info block, and a Cues element that maps timestamps to byte offsets so a player can jump. Chrome's MediaRecorder muxes while it captures, so it writes an unknown-size Segment, skips Duration because it doesn't know the answer yet, and never goes back to append Cues when you call stop(). Calling start(timeslice) or requestData() makes this certain, since each chunk has to be independently emittable.

This is a known and still-unresolved gap, not a bug in your code. Chromium issue 40482588, "MediaRecorder: consider producing seekable WebM files," is open. The W3C mediacapture-record repo has been arguing the same point since issue #119 in 2022. The npm packages fix-webm-duration and webm-duration-fix exist entirely because of it.

The damage shows up everywhere downstream. An HLS packager can't segment a file with no duration. FFmpeg's concat demuxer produces garbage timestamps when joining an unindexed recording to anything else. A -ss 00:04:30 seek on a file with no Cues has to decode from byte zero to get there. Cloud transcoders and upload validators frequently reject N/A duration outright.

How to tell a recording is missing its duration and index

Detection takes one ffprobe call, and the answer is unambiguous. Run this against the uploaded blob before you queue any work on it:

ffprobe -v error -show_entries format=duration \
  -of default=nw=1:nk=1 recording.webm

A normal file prints something like 184.320000. A MediaRecorder blob prints N/A. That single line is a good ingest gate, and it's cheap because ffprobe only reads the header.

To find the real length you have to read every packet, since the last presentation timestamp is the only honest source:

ffprobe -v error -select_streams v:0 -show_entries packet=pts_time \
  -of csv=p=0 recording.webm | tail -1

In the browser the same check is one comparison: if (video.duration === Infinity). The old trick of setting video.currentTime = 1e101 to force Chrome to scan to the end does make the player report a duration, but it changes nothing about the bytes you uploaded. The file in your bucket is still unindexed.

The fast fix: remux the WebM and write the cues to the front

Remuxing rebuilds the container without touching the video or audio bitstreams. ffmpeg -i recording.webm -c copy -cues_to_front 1 fixed.webm fixes a MediaRecorder blob without re-encoding: the matroska muxer writes a real Duration element in Info, builds a Cues index, and places that index at the head of the file so a player can seek off the first range request.

ffmpeg -i recording.webm -c copy -cues_to_front 1 fixed.webm

Two constraints on -cues_to_front. It landed in FFmpeg 5.1, so a Debian box on FFmpeg 4.x will reject the option. And it needs a seekable output, meaning a real file, not a pipe to stdout or a streaming upload. Pipe it and you get a file with cues at the end, which still seeks in Chrome but not on a partial range fetch.

Because nothing is decoded, this runs at disk speed. Quality is bit-identical to the recording, which matters if you're archiving the master.

When you need MP4: transcode with faststart

If the file has to play in Safari, feed an ad platform, or go into a template render, WebM won't do and -c copy won't save you. VP8 has no usable MP4 mapping, and while VP9 and Opus can technically live in MP4, player support is patchy enough that shipping it is a support ticket generator. That means a real transcode:

ffmpeg -i recording.webm \
  -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p \
  -c:a aac -b:a 128k \
  -movflags +faststart \
  recording.mp4

-pix_fmt yuv420p keeps the output decodable on hardware players. -movflags +faststart does a second pass that moves the moov atom to the front, which is the MP4 equivalent of cues-to-front and the difference between a video that starts instantly and one that downloads fully first. The other flags that decide whether a browser plays your MP4 at all are covered in MP4 Not Playing in Browser? Four Encoder Flags Fix It.

The cost here is CPU, and it's the part that breaks serverless plans. Chrome's MediaRecorder defaults to roughly 2.5 Mbps of video, so a 10-minute screen capture arrives around 190 MB. That's a multi-minute encode on a single core, against an AWS Lambda ceiling of 15 minutes and a default 512 MB of /tmp you have to raise before the input even lands.

This is the step FFmpeg Micro replaces: send the recording or its URL, get a job back, and download a seekable MP4 with faststart already applied. No encoder to host, no timeout to design around, and it's callable from code, from n8n, Make, and Zapier, or from an AI agent. The request shape is in the docs.

Why the fix belongs on the server, not in your browser bundle

The browser-side libraries are real and they work, but they solve a narrower problem than most teams assume. Patching the EBML header injects a Duration so the player stops saying Infinity. It does not build a Cues index, so scrubbing a long recording still forces a linear scan, and the archived file is still unindexed for whatever reads it next.

Each approach fixes a different part of the problem:

ApproachFixes durationFixes seek indexCost
`currentTime = 1e101` hackIn that one player onlyNoFree, fixes nothing on disk
`fix-webm-duration` / `webm-duration-fix`YesNoFew KB of JS, whole blob in memory
`ts-ebml` rewriteYesYesWhole file as an ArrayBuffer in the tab
WebCodecs remux in-browserYesYesUser's CPU, uneven browser support
Server remux or transcodeYesYesOne job, zero client code

The memory line is the one that bites. Holding a 190 MB ArrayBuffer plus the rewritten copy in a mobile Safari tab is how you get a silent crash on exactly the users who recorded the longest sessions. You already have a server step, because the blob has to be uploaded somewhere. Fixing it there costs one job and no client bytes.

Common pitfalls

Most of the failures in this flow come from the chunked upload path rather than from FFmpeg itself.

  • Only the first blob from start(timeslice) carries the EBML header. Upload chunks out of order or drop chunk zero and you get an unplayable file, not a partial one.
  • Don't force -r 30 on a screen recording. getDisplayMedia output is variable frame rate, and pinning a constant rate introduces drift instead of removing it.
  • MediaRecorder.pause() leaves timestamp gaps that a -c copy remux faithfully preserves. If pauses need to close up, you need a re-encode with regenerated timestamps.
  • -movflags +frag_keyframe+empty_moov is for streaming output while it's still being written. It's the wrong choice for a final deliverable, where you want +faststart.
  • ffprobe every upload before you queue work on it. Rejecting a bad file at ingest is far cheaper than debugging it three jobs later, which is the same argument made in FFmpeg Invalid Data Found Isn't a Corrupt Video.

When a server round trip isn't worth it

Short clips that never leave the tab don't need any of this. If you're recording a 15-second webcam response, playing it back immediately for confirmation, and discarding it, the client-side duration patch is the right amount of engineering. Send it to a server only when the file gets stored, shared, or processed again.

Recording in MP4 directly is also worth checking, since MediaRecorder.isTypeSupported('video/mp4') returns true in Safari and in recent Chrome builds. Test at runtime rather than assuming by version. Be aware that a browser-written MP4 is typically fragmented with the index at the end, so you'll still want a faststart pass before delivery.

And if what you actually need is a composed video with templates, transitions, and a timeline, a container fix isn't your problem. That's a rendering job, and a template-based rendering service is a different class of tool.

FAQ

Why does my MediaRecorder WebM show duration Infinity?

Chrome's MediaRecorder writes WebM as a live stream, so the file has no Duration element in its Info block and no Cues index. The browser reports Infinity because the container never states a length, not because the recording is damaged.

Can I convert MediaRecorder WebM to MP4 without re-encoding?

You can't. VP8 has no practical MP4 mapping, and VP9 or Opus inside MP4 plays inconsistently across browsers and devices, so -c copy into an MP4 container gives you a file most players refuse. Re-encoding to H.264 and AAC is the reliable path; if you only need the file to seek, remux it as WebM with -c copy -cues_to_front 1 instead.

Does fix-webm-duration make the file seekable?

fix-webm-duration writes a Duration into the EBML header so the player stops reporting Infinity, but it does not build a Cues index. Seeking still requires scanning, which is unnoticeable on a 20-second clip and painful on a 40-minute one.

How do I convert WebM to MP4 with an API instead of hosting FFmpeg?

Post the recording or a URL to a video processing API and let it run the transcode as a job, then poll or take a webhook for the finished MP4. FFmpeg Micro does exactly this on a free tier, which removes the encoder install, the Lambda timeout ceiling, and the disk provisioning from your stack.

Will this work inside an n8n or Make workflow?

Yes. The pattern that holds up is uploading the blob to storage first and passing the resulting URL to the processing step, because passing large binaries between workflow nodes is what exhausts memory. That approach is covered in Google Drive Video Automation Fails in n8n. Pass a URL Instead.

If browser recordings are already piling up in your bucket with N/A durations, the fix is one API call per file and the free tier is enough to reprocess a backlog. Sign up free and point it at one recording to see the seekable MP4 come back.

About Javid Jamae

Founder & CEO at FFmpeg Micro

Javid is a software engineer, author, and entrepreneur with over 25 years of professional software development experience across enterprise, startup, and consulting environments. He founded FFmpeg Micro to make video processing accessible to developers through a simple, automation-first REST API.

Software EngineeringVideo ProcessingFFmpegCloud ArchitectureAPI DesignAutomation

Ready to process videos at scale?

Start using FFmpeg Micro's simple API today. No infrastructure required.

Get Started Free