The lag was the debugger


TL;DR — “First tap is slow” and “recording feels heavy” in my real-time camera app were mostly the cost of running under Xcode with the debugger attached. Vision detection runs several times slower in that setup. The TestFlight release build was smooth. Judge fps and stutter only in a release build on device. Separately, one first-use hitch was real: CoreImage compiling its Metal pipeline on first use. That one is fixed by prewarming — but only if the prewarm goes through the same call path as the live code.

What was running

VlogMask is a real-time camera that mosaics faces while you record. Each frame: detect faces, match them to the previous frame, mosaic the ones that aren’t protected, show the preview, and write the frame to disk. 4K, 30fps target, all on device.

That budget is tight. At 30fps you get 33 ms per frame for everything.

The symptom

Two complaints, both mine, both from testing on device from Xcode:

  • The first tap — selecting the face to protect, or starting a recording — felt slow.
  • Recording felt heavy.

Neither crashed. Neither showed an error. They just felt bad, and “feels bad” in a camera app is a real problem.

The numbers I was optimizing against

I measured per-stage timings. On device, 4K, Debug run from Xcode:

Stage Time
Mosaic render ~0.3 ms
Face detection, warm (full frame + tiles) ~50 ms per detection frame
Face detection, first call 229 ms (Vision model’s first load)

These numbers tell a clear story. Render is free. Detection is the whole cost. 50 ms is more than one frame’s budget, and the first detection is a very visible 229 ms stall.

Detection ran every second frame, so the average was 50 ms of work per 66 ms of wall clock. On paper that fits 30fps, but with almost no headroom. The obvious next step was to move detection off the capture queue so a slow detection can never block a frame.

That plan was reasonable given the numbers. The problem was the numbers.

What ended it

I shipped a TestFlight build and tried the same things.

The first tap was fine. Recording was smooth. The problem I had been chasing mostly wasn’t there.

Running under Xcode with the debugger attached adds a large cost, and Vision detection was several times slower in that setup than in the release build. Every measurement above was taken in the setup I would never ship. I was tuning the performance of a configuration no user will ever run.

I didn’t record the matching release-build numbers at the time, so I can’t give you a clean before/after table. What I can say is what I decided from it: the async-detection refactor was not needed, because release was already smooth. I didn’t do it.

What was real

Not everything was the debugger. One first-use hitch was real and showed up in release too: the first mosaic stuttered, because CoreImage compiles its Metal pipeline the first time you render through it.

The fix is to prewarm — render once at session start, before the user does anything. Two details made the difference:

Warm each context separately. The preview draws into a Metal texture. Recording draws into a CVPixelBuffer through a separate CIContext. Warming one does not warm the other. I prewarm both: MetalPreviewRenderer.prewarm() and MosaicRenderer.prewarm().

Warm through the exact live call path. My first attempt used createCGImage as a cheap way to “touch” the pipeline. It did nothing for the live path, because it compiles a different pipeline. Prewarming only works if it makes the same call the live code makes, into the same kind of destination.

Later, detection did move

Some time after this, I added more detection passes — full frame plus a 3×3 grid of zoomed tiles, to catch small faces far away. With detection running synchronously on the capture path, selecting a face then caused visible stutter.

So detection moved to its own queue after all: one detection in flight at a time, late frames dropped, preview and recording stay at 30fps.

The refactor happened, but for a symptom that was actually there — not because of numbers taken under the debugger.

It happened again

In a later project, VoiceKit, I measured WhisperKit transcription speed during a proof of concept. Debug was several times slower there too. That time I knew to switch to a release build before drawing conclusions.

Takeaway

Measure performance in the configuration you ship. Debug plus an attached debugger is a different program — slower in ways that aren’t uniform across stages, so it doesn’t just scale your numbers, it can change which stage looks like the bottleneck.

For a real-time app on iOS, that means a release build on device. TestFlight is an easy way to get there. Use Debug to find out why something is slow. Use release to find out whether it is.


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.
  • VertiCut — turns horizontal video into vertical 9:16, following a person automatically.