AVAssetWriter stalls silently on offline export


TL;DR — If an offline AVAssetWriter export stops partway through with no error — isReadyForMoreMediaData stuck at false forever, requestMediaDataWhenReady never firing again, writer.status still .writing — set expectsMediaDataInRealTime = true on your inputs. Yes, even though nothing about your pipeline is real-time. Set it on the audio input too.

iPhone 17 Pro, iOS 26.6, Xcode 26.6 (17F113).

The setup

I build small iOS apps on my own. One of them, KidsMask, applies a mosaic to faces in a video the user picked from their library and writes the result back out. Ordinary offline pipeline, no capture session anywhere:

AVAssetReader → per-frame face detection and mosaic → AVAssetWriter.

The symptom

On 4K clips, the export stopped partway through. Not at the end, not at the start — somewhere between frame 37 and frame 74, varying by clip.

What “stopped” meant, precisely:

  • isReadyForMoreMediaData went false and never came back true.
  • My requestMediaDataWhenReady(on:using:) callback was never invoked again.
  • writer.status stayed .writing. writer.error was nil.
  • The app stayed alive. Memory stayed low. No CPU spike.

No exception, no error, no crash report. The export just quietly ceased to be an export. This is the worst shape a bug can take, because there is nothing to search for — you can’t paste a stack trace into Google when there isn’t one.

What actually got me out

I want to be honest about the debugging, because the thing that solved it wasn’t clever reasoning about VideoToolbox.

I had another app of mine that used AVAssetWriter and worked. VlogMask is a real-time vlog camera — it mosaics faces live and records the mosaicked frames straight to disk. Same writer class, same H.264 encoder, same 4K, same device. It had never stalled once.

So I stopped theorizing and diffed the two configurations line by line.

The difference was one property. VlogMask set expectsMediaDataInRealTime = true, because it genuinely is real-time — I’d set it years earlier without thinking about it. My offline pipeline set it to false, because every guide on offline processing says that’s what false is for.

// The offline pipeline. This is what stalled.
videoInput.expectsMediaDataInRealTime = false

// This is what fixed it. Both inputs.
videoInput.expectsMediaDataInRealTime = true
audioInput.expectsMediaDataInRealTime = true

Set it on the audio input as well. Same flag, same reason.

Why it happens

My reading of it — mechanism inferred from behaviour, not confirmed against AVFoundation internals:

With expectsMediaDataInRealTime = false, you’re telling the input that frames arrive on no particular schedule and nothing needs to be dropped. The encoder responds by building a deep lookahead queue, which is a reasonable thing to do when you have time to spare and want better compression decisions.

But an offline pipeline doesn’t feed frames on a real-time schedule. It feeds them in bursts, as fast as the reader and the CPU allow — far faster than 30fps. Fill that lookahead queue faster than it drains and it jams. Once it jams, isReadyForMoreMediaData latches false and nothing ever unlatches it.

The frame count fits: 37–74 frames is roughly the depth of a lookahead queue, not a random point in the file.

With true, the input paces itself against a real-time budget, the queue stays shallow, and the jam never forms. Which is the ironic part — the flag that sounds like it’s for live capture is the one that makes bulk processing survive.

The counterintuitive bit

The naming works against you here. expectsMediaDataInRealTime reads as a description of your source. It behaves more like a description of your pacing strategy. My source was a file on disk; my pacing still needed to look real-time to the encoder.

If you’ve read “set it to false for offline processing” somewhere and it’s in your code because of that, this is worth ten seconds of checking.

One more, while you’re in there

Unrelated to the stall, but it’s the other AVAssetWriter thing that has cost me time: finishWriting(completionHandler:) is asynchronous, and status stays .writing until it completes. If two paths can finish the same export — a user cancel racing a teardown, say — the second call hits Cannot call method when status is 1 and crashes the app.

Guard it with an isFinishing flag so the second caller is a no-op. Cheap, and it removes a whole class of crash you’d otherwise find in production.

Takeaway

When one of your codebases works and another doesn’t, diff the configuration before you theorize about the framework. I had a known-good AVAssetWriter sitting in another project the whole time. The path to the answer was comparing two files, not reasoning about encoder internals — and I’d have found it days earlier if I’d reached for that first.


I’m Kou (日々野 考), an indie iOS developer in Japan building on-device ML apps under kouworks. I write up the things that cost me days, so they cost you minutes.

Apps: VoiceKit — filler-word removal, subtitles, and summarization, entirely on-device. KidsMask — automatic face mosaic for photos and video; no face data ever leaves the device. VlogMask — real-time face mosaic camera for vlogging.