A file that starts with moof
Re-entering the start function across its await threw away the initialisation segment. I blamed iOS and fixed for it, which did nothing; printing the first bytes of the upload settled it.
On 8 September a new user of my OPIc speaking-practice app kept failing analysis, starting three minutes after signing up. The app records a user's answer, sends it to a model and returns an assessment, and the server log held one fact about those failures: the model API had returned 400 INVALID_ARGUMENT.
Suspecting iOS
There were two failures in the log and both were iOS. 18.7 and 18.7.1.
A plausible hypothesis formed immediately. MediaRecorder on iOS Safari is notorious for its container support. We were recording webm, so I thought maybe something in iOS 18 was producing a file the model couldn't read.
So I detected iOS 18 and switched it to record mp4. Safari on iPad pretends to be a Mac and plain iOS detection doesn't work, so I added iPad desktop-mode detection along the way.
I deployed. The same user failed again with the same symptom.
Changing the container and getting the same result means the container wasn't the cause. That's when I noticed something odd: the evidence had never pointed at that hypothesis. I'd picked a cause from a single shared trait, iOS, and left it there.
I stopped guessing and added instrumentation
It took a miss on iOS before I admitted the order was backwards. Nobody had ever looked at what a failing request actually uploaded.
I added diagnostics that record the leading bytes of the uploaded file, its container header and its effective bitrate whenever a failure occurs.
The next morning the answer arrived. All five failures on 9 September uploaded files beginning with the same bytes.
000001346d6f6f66…
m o o fmoof is the fragment box of a fragmented MP4. It belongs in the middle of a file, not at the start. The ftyp and moov boxes that should precede it were absent. The initialisation segment, which carries codec and track configuration, was missing entirely.
I didn't work this out myself either. I pasted the bytes into Claude Code and it told me they were the fragment box of a fragmented MP4. That was the first time I'd heard the name moof, and a bit of reading afterwards seemed to agree. Media containers aren't my field. I was willing to suspect the kind of container and never once suspected whether the container was well formed, because I didn't know what was supposed to be inside one in the first place.
No decoder can read a file like that. The model server returning 400 was correct.
Two calls into the start function
MediaRecorder emits the initialisation segment exactly once, as the first chunk when it starts. Its absence means the first chunk was thrown away.
The start function looked like this: clear the buffer, request microphone permission, create the recorder and start it. The problem is that getUserMedia in the middle is awaited. It takes hundreds of milliseconds, and the function stays open across them.
01Clears the buffer and waits on getUserMedia. Hundreds of milliseconds.
I checked the chunk order before and after the fix.
before [moof-A, moof-A, INIT-B, …]
after [INIT-B, moof-B]Three things changed. A re-entry lock across the await; if a previous recorder is alive, detach its data handler first and then stop it; and a per-session local array that the handler closes over instead of a shared ref.
The middle one hides a trap. If you detach onstop as well while cleaning up the previous recorder, the promise of an in-flight stop never resolves. Detach the data handler only, and leave onstop alone.
Then I looked at the numbers again
Having found the cause, I went back over the failure counts I'd been quoting.
| Date | Success | Failure | Rate |
|---|---|---|---|
| 9/05 | 265 | 0 | 0% |
| 9/06 | 233 | 0 | 0% |
| 9/07 | 220 | 7 | 3.1% |
| 9/08 | 273 | 5 | 1.8% |
| 9/09 | 108 | 5 | 4.4% |
The seven on 7 September looks like the worst day. Six of them were local tests I ran myself. One real user failed.
Recounted properly:
My own test traffic had leaked into a production failure metric. Read in a hurry it shows 3.1%; what actually happened to users was 0.45%.
It needs at least another day
I can't say it's fixed yet. The deploy went out at 12:30 today and in the three hours and fifteen minutes since there have been 39 successes and no failures. The window immediately before it had five failures in seventy, so it looks good, but failures were running at about five a day and three clean hours happens by chance easily enough. I think a full day is the minimum.
I still don't know whether iOS 18 is innocent either. It's a fact that all 11 real failures were iPhones on 18.7. The defect itself is a platform-independent race, but it's plausible that slower microphone-permission handling widens the await window and therefore the race. That's a guess, though. Timing getUserMedia per device would probably settle it.
And no user ever reported any of this. Every incident found in this app so far surfaced through logs and alerts; not one arrived as a user report. The user whose analyses kept failing within three minutes of signing up didn't send one either.
If the one line that prints the first sixteen bytes of the upload had been there from the start, I don't think it would have taken two days.
That's as far as I'm taking it tonight. I'll look at the numbers again in the morning.
End