Series: Engineering gotchas
I put a batch of podcast audio files (.m4a) on Cloudflare R2 and embedded them with a plain <audio> tag. Desktop Chrome and Firefox played them perfectly. But on iPhone and iPad, they wouldn't play at all — tap the button, nothing happens, no error message, console completely clean.
The most maddening part: it throws no error. You have no idea where to even start looking.
This is how I tracked it down to the root cause, and the one-line fix at the end. If you googled "audio plays on desktop but not iphone" or "m4a not playing ios safari" and landed here, skip to section three.
First, rule out all the usual suspects
iOS Safari is picky about audio, and the three most common answers online are: wrong MIME type, no HTTP Range support, or bad codec. I checked them one by one — and they were all correct.
# 1) Is the Content-Type right?
curl -sI "https://your-domain/xxx.m4a"
# → Content-Type: audio/mp4 ✅ correct (m4a is audio/mp4)
# → Accept-Ranges: bytes ✅ Range supported
# 2) Does a Range request actually return 206? (iOS strictly requires this)
curl -s -o /dev/null -D - -H "Range: bytes=0-99" "https://your-domain/xxx.m4a" | grep HTTP
# → HTTP/1.1 206 Partial Content ✅ correct
The server side is entirely correct. Content-Type is audio/mp4, Range is supported, it returns 206 Partial Content. R2 is fine. CORS isn't the issue either (an <audio> without the crossorigin attribute plays cross-origin without needing CORS).
And the codec is .m4a (AAC) — the format iOS supports most natively. All three common culprits ruled out. So what is it?
The real culprit: the moov atom is at the end of the file (no faststart)
An MP4 / m4a file is made of boxes (also called atoms). Two matter most here:
-
mdat: the actual audio / video data (large — 11 MB in my file) -
moov: the playback index / metadata (timeline, sample tables — the player must read this first to know how to decode)
Here's the problem: ffmpeg's default output puts mdat first and moov at the very end of the file.
-
Desktop browsers (Chrome / Firefox) are smarter / more aggressive: they fire a Range request for the end of the file to grab
moov, find the index, and play. -
iOS Safari / WebKit, with
<audio preload="metadata">, only fetches the beginning of the file. Nomoovat the start → it can't find the index → it silently gives up and won't play.
That's why it plays on desktop but not on iPhone / iPad. Both iPhone and iPad use WebKit, so they fail together.
Moving moov to the front of the file is called faststart — the standard trick that lets video / audio "play while downloading."
How to confirm it (30 seconds)
Grab the first 48 bytes and check whether ftyp is followed by moov or mdat:
curl -s -H "Range: bytes=0-47" "https://your-domain/xxx.m4a" | xxd | head -3
Broken (no faststart) — mdat right after ftyp:
00000000: 0000 001c 6674 7970 4d34 4120 ... ....ftypM4A
00000020: 6672 6565 00a8 3b91 6d64 6174 ... free..;.mdat ← mdat first, moov at the end
Good (faststart) — moov right after ftyp:
00000000: 0000 001c 6674 7970 4d34 4120 ... ....ftypM4A
00000020: 6d6f 6f76 0000 006c 6d76 6864 ... moov...lmvhd ← moov right after ftyp ✅
If mdat comes before moov, that's your bug.
The fix: one line of ffmpeg, lossless, no re-encode
ffmpeg -i input.m4a -c copy -movflags +faststart output.m4a
The key is -c copy — it does not re-encode; it just copies the existing stream as-is and relocates moov to the front. So:
- Lossless: audio quality is untouched
- Same size (mine was 11,258,702 bytes before and after — not a single byte different)
- Instant: 11 MB in under a second
Then re-upload to overwrite the original. If your files are on R2 / S3, keep Content-Type: audio/mp4 on the upload.
(If a CDN like Cloudflare sits in front, check whether the edge cached the old file. Mine was cf-cache-status: DYNAMIC — not cached — so the overwrite took effect immediately. The only remaining cache is the phone browser's local cache — test in a private tab for a clean result.)
How to stop it from happening again
If you have a "generate audio → upload" pipeline, always run it through faststart before uploading — never push the raw ffmpeg / converter output straight up. I baked it into the upload script as a mandatory step: remux faststart → upload → auto-verify with curl that moov really is at the front, all three together. New audio never hits this bug again.
This bug is a good reminder: "works on my machine" and "works on the user's device" are two different things. The server config was all correct, the file was there, desktop played fine — every "obvious" place was clean, and the defect hid in the order of the atoms inside the MP4. Problems that only surface on real devices (especially iOS) slip right past automated tests and desktop development. Next time you hit "fine on desktop, broken on mobile," don't just stare at your code — think file format / platform differences.
本文原載於我的部落格:Audio Plays on Desktop but Not on iPhone / iPad — The Culprit Is the MP4 moov Atom