MP4 Not Playing in Browser? Four Encoder Flags Fix It

Your encode finished, VLC plays it back perfectly, and you ship it. Then the <video> tag renders a black box in Chrome, Safari shows a crossed-out play button, and QuickTime refuses to open the file. The file isn't corrupt. It's just encoded in a way VLC forgives and browsers don't.
Quick answer: An MP4 not playing in browser almost always fails on one of four things: a pixel format other thanyuv420p(screen recorders and OBS often emityuv444p), an H.264 profile or level above what the target decodes, the moov atom sitting at the end of the file so playback can't start progressively, or an audio codec like PCM or AC-3 that no browser touches. The fix is one encode:ffmpeg -i in.mov -c:v libx264 -profile:v high -level:v 4.0 -pix_fmt yuv420p -crf 20 -c:a aac -b:a 128k -ac 2 -movflags +faststart out.mp4. If you'd rather not run an encoder to guarantee those four flags on every upload, FFmpeg Micro runs the same encode as one API call with no servers to babysit.
VLC plays broken files on purpose, so it can't tell you if a file is deliverable
VLC ships its own copy of libavcodec and will decode 4:4:4 chroma, 10-bit, High 4:4:4 Predictive profile, PCM audio in an MP4 container, and a file whose index is in the wrong place. That's the point. VLC is a player of last resort, tuned to play anything.
A browser is the opposite. Chrome, Safari, and Firefox hand H.264 to a platform or hardware decoder with a fixed contract: 8-bit 4:2:0 chroma, a profile the decoder advertises, and audio it has a licensed decoder for. Anything outside that contract isn't degraded playback, it's no playback. So "it works in VLC" tells you the bytes decode, not whether they'll decode on an iPhone.
Chrome doesn't print a codec error in the page either. Read the element's error object in the console:
const v = document.querySelector('video')
v.error.code // 4 = MEDIA_ERR_SRC_NOT_SUPPORTED
v.error.message // often "DEMUXER_ERROR_COULD_NOT_OPEN" or a codec-not-supported string
Code 4 means the browser looked at the track and declined.
The four flags that decide whether a browser plays your MP4
Four encoder settings account for nearly every "plays locally, dies on the web" report, and the table lists what each one does and the flag that fixes it.
| Setting | What VLC accepts | What browsers require | Flag |
|---|---|---|---|
| Chroma / bit depth | 4:4:4, 4:2:2, 10-bit | 8-bit 4:2:0 | `-pix_fmt yuv420p` |
| H.264 profile/level | anything x264 emits | High profile, level ≤ 4.2 | `-profile:v high -level:v 4.0` |
| Index placement | moov anywhere | moov before mdat | `-movflags +faststart` |
| Audio codec | PCM, AC-3, Opus-in-MP4 | AAC-LC, stereo | `-c:a aac -b:a 128k -ac 2` |
Pixel format is the one that bites screen recorders
-pix_fmt yuv420p forces 8-bit 4:2:0 chroma subsampling, the only H.264 chroma format every browser decoder is guaranteed to handle. If your source came out of OBS with Color Format set to I444 or RGB, or a capture tool that preserves full chroma for text sharpness, x264 encodes High 4:4:4 Predictive and VLC plays it beautifully. The browser reports code 4.
Confirm the rejection before you upload:
MediaSource.isTypeSupported('video/mp4; codecs="avc1.640028,mp4a.40.2"') // true (High @ L4.0 + AAC-LC)
MediaSource.isTypeSupported('video/mp4; codecs="avc1.f4002a"') // false (High 4:4:4 Predictive)
Ten-bit output is the same failure in different clothes. Some FFmpeg builds ship an x264 that defaults to 10-bit, and yuv420p10le fails everywhere yuv444p does. Setting -pix_fmt yuv420p covers both.
Profile and level describe what the decoder must be able to do
H.264 profile and level are a promise about decoder workload, and a hardware decoder that can't meet it refuses the stream rather than trying. -profile:v high -level:v 4.0 covers 1080p30 on every browser and iOS device shipped in the last decade. Push to -level:v 4.2 if you're delivering 1080p60. Drop to -profile:v main -level:v 3.1 only for genuinely old set-top hardware, and accept that the file will be roughly 5 to 10 percent larger at the same quality because Main loses CABAC's 8x8 transform.
What breaks is the top end. x264 with no -level picks a level from your resolution and bitrate, and a high-bitrate 4K encode can land at level 5.1 or 5.2 that a phone's decoder won't take.
moov atom placement decides whether playback ever starts
The moov atom is the index of an MP4, and -movflags +faststart moves it to the front so a browser can start decoding after the first few kilobytes instead of the last byte. FFmpeg writes moov at the end by default because it doesn't know the final track sizes until it's done. A local player reads the whole file off disk and never notices. A browser streaming over HTTP does.
Modern browsers usually recover by issuing a Range request for the file's tail, so the symptom is often a long stall. On a server that doesn't honor Range requests, or a signed URL that rejects them, it's a hard failure. Truncated uploads produce the harsher version, the moov atom not found error your ingest step should catch.
Audio is the quiet half of the failure
Browsers decode AAC-LC in MP4 and very little else, so -c:a aac -b:a 128k -ac 2 -ar 48000 is the setting that survives everywhere. This one produces the bug report where video plays with no sound, or plays in Chrome and is silent in Safari.
The usual cause is a remux. Copy a ProRes MOV's audio with -c copy and you've put pcm_s16le inside an MP4 container, which is legal MP4 and unplayable in every browser. AC-3 and E-AC-3 only decode where the platform licensed them. Opus is fine in WebM and unreliable in MP4 on Safari. Six-channel audio too: force -ac 2 unless you know the player downmixes.
The canonical web-safe encode
One command sets all four, plus two guards for real-world input.
ffmpeg -i input.mov \
-c:v libx264 -profile:v high -level:v 4.0 \
-pix_fmt yuv420p -crf 20 -preset medium \
-vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" \
-c:a aac -b:a 128k -ac 2 -ar 48000 \
-movflags +faststart \
web-safe.mp4
The scale=trunc(iw/2)*2 filter exists because 4:2:0 chroma can't represent odd dimensions, so a 1439-pixel-wide crop from a screen recording throws height not divisible by 2 the moment you add -pix_fmt yuv420p. CRF 20 with -preset medium puts 1080p30 screen content somewhere around 2 to 4 Mbps, so a 60-second clip lands near 15 to 30 MB. Drop to CRF 23 for mobile delivery.
-pix_fmt applies at the encoder while filters run before it, so a chain ending in an RGB filter can surprise you. Append format=yuv420p as the last filter in complex chains.
The ffprobe checklist to run before you call the job done
Three ffprobe commands verify all four flags landed. They belong in your pipeline as an assertion, not as something you run after a complaint.
# 1. Video: profile, level, chroma
ffprobe -v error -select_streams v:0 \
-show_entries stream=codec_name,profile,level,pix_fmt,width,height \
-of default=noprint_wrappers=1 web-safe.mp4
You want codec_name=h264, profile=High, level=40, pix_fmt=yuv420p, and even width and height. ffprobe reports level as an integer, so 40 is level 4.0 and 42 is 4.2.
# 2. Audio: codec, profile, channels
ffprobe -v error -select_streams a:0 \
-show_entries stream=codec_name,profile,channels,sample_rate \
-of default=noprint_wrappers=1 web-safe.mp4
You want codec_name=aac, profile=LC, channels=2. Anything reporting pcm_s16le, ac3, or channels=6 surfaces as a silent or unplayable file.
# 3. Faststart: moov must appear before mdat
ffprobe -v trace web-safe.mp4 2>&1 | grep -o -m 2 -e "type:'moov'" -e "type:'mdat'"
Correct output prints type:'moov' first. If mdat comes first, faststart didn't apply, which happens quietly when you pass -movflags after the output filename or combine it with a streaming muxer that can't seek.
Running that trio on every generated file separates a pipeline that ships deliverable video from one that ships files QuickTime won't open. FFmpeg Micro handles the encode and the checks as a hosted step: the web-safe preset is one API call from code, n8n, Make, or Zapier, with no encoder version to pin or box to keep warm.
Common pitfalls
The four flags fix most files. These cases survive the fix and still fail.
-c copywon't fix chroma or profile. Remuxing with-movflags +faststart -c copyrelocates the moov atom without re-encoding, the right call for a faststart-only problem. It cannot changeyuv444ptoyuv420p; that takes a real encode.- HEVC tagged
hev1instead ofhvc1. Safari plays HEVC in MP4 only when the sample entry ishvc1, so add-tag:v hvc1if you're delivering H.265. Chrome on many Windows machines still won't take it. For anything that must play everywhere, use H.264. - iPhone rotation metadata. A vertical iPhone recording is often stored as 1920x1080 with a 90-degree display matrix. Filters apply before rotation is honored, so a scale filter can output sideways video. See the MOV to MP4 recipe for the ordering that works.
- Server MIME type. A correctly encoded file served as
application/octet-streamstill won't play inline. It has to bevideo/mp4. - Fixing it at the wrong end of the pipeline. Re-encoding per platform to work around playback failures solves the symptom. One encode covers every platform once these four flags are set.
When re-encoding is the wrong move
Re-encoding costs a generation of quality, so it isn't always the answer. If ffprobe says the file is already h264 / High / 40 / yuv420p with AAC-LC audio and it still won't play, stop touching the encoder. The problem is delivery: Range request support, MIME type, CORS headers on a cross-origin <video> source, or a signed URL expiring mid-playback.
Long files are the other case. A 90-minute recording delivered as one progressive MP4 starts slowly no matter where the moov atom is, and the answer is HLS with a bitrate ladder, not a better single-file encode. If what you need is a timeline editor with keyframes and transitions, an API isn't that; a template-editor product is the right class of tool.
FAQ
Why does my MP4 play in VLC but not Chrome?
VLC bundles its own decoders and accepts 4:4:4 chroma, 10-bit color, PCM audio in MP4, and non-standard profiles that browsers reject outright. Chrome hands the stream to a platform decoder that only supports 8-bit 4:2:0 H.264 with AAC-LC audio, so a file VLC plays fine fails with MEDIA_ERR_SRC_NOT_SUPPORTED.
What pix_fmt should I use for browser playback?
Use -pix_fmt yuv420p for any MP4 you plan to play in a browser. It's 8-bit 4:2:0 chroma, the only H.264 pixel format supported across Chrome, Safari, Firefox, Edge, and iOS. OBS and screen recorders set to I444 or RGB produce yuv444p, which no browser decodes.
Can I fix an existing MP4 without re-encoding it?
You can fix moov atom placement without re-encoding by running ffmpeg -i input.mp4 -c copy -movflags +faststart output.mp4, which remuxes in seconds at any file length. Pixel format, H.264 profile, and audio codec live inside the encoded streams, so changing those requires a full re-encode.
Which H.264 profile and level should I target?
Target High profile at level 4.0 for 1080p30 and level 4.2 for 1080p60. That combination decodes on every mainstream browser and on iOS devices going back a decade. Letting x264 pick the level on a 4K source can land you at 5.1 or 5.2, which mobile hardware decoders refuse.
Does -movflags +faststart make the file bigger?
The +faststart flag doesn't change file size. It rewrites the file once at the end of encoding to move the moov atom ahead of the media data. The cost is a second pass over the output on disk: a few seconds on a large file, nothing measurable on a short clip.
Paste a file into the playground to see what the web-safe preset produces, then sign up free when you want it running on every upload.
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.
You might also like

Too many packets buffered? max_muxing_queue_size is the last fix
FFmpeg's "Too many packets buffered for output stream" error is a stream mapping problem. Fix the cause first, then size max_muxing_queue_size without an OOM.

FFmpeg Audio Out of Sync Isn't the Codec. It's the Frame Rate
FFmpeg audio out of sync is usually a variable-frame-rate source hitting a fixed -r. Detect VFR with ffprobe, then fix it with -fps_mode cfr and aresample.

Fix FFmpeg's "height not divisible by 2" Error on User Uploads
FFmpeg's "height not divisible by 2" error is a yuv420p chroma constraint, not a broken file. Three fixes (scale=-2, trunc, pad) with measured output sizes.
Ready to process videos at scale?
Start using FFmpeg Micro's simple API today. No infrastructure required.
Get Started Free