OPIc appcaptureMediaRecorderdiagnosis

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.

Sep 9, 20266 min

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 f

moof 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.

UserStart functionRecorder AChunk bufferfirst tapstart recorder Asecond tapclear the buffera fragment every 100msa file starting with moof
1 / 6

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.

DateSuccessFailureRate
9/0526500%
9/0623300%
9/0722073.1%
9/0827351.8%
9/0910854.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:

11
real user failures out of 17 total. The other six were test requests I generated
2
actual people behind those 11. The same person retried several times
iOS 18.7
every one of the 11 was an iPhone on 18.7. This is why the iOS hypothesis looked so plausible

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