AVCaptureSession bugs that only show up on real devices
The iOS simulator has no camera. That single fact is the source of more late-night bug reports than anything else I've shipped — because it means every camera bug is, by definition, a bug you cannot see until the app is running on a phone. The simulator doesn't fail loudly. It lies politely.
Open an AVCaptureSession in the simulator and you get a black preview, no error, and a green checkmark on your test suite. Everything looks wired up. Then you build to a five-year-old iPhone, hand it to a tester, they rotate it, take a phone call, and the whole capture pipeline falls over. I've shipped 50-odd apps and a good chunk touch the camera; the failures cluster into the same handful of places every time. Here's the list I now check before any camera feature goes near TestFlight.
Configure the session off the main thread, and bracket every change
The first mistake is treating AVCaptureSession like a synchronous object you can poke from wherever you happen to be. Calling startRunning() on the main thread blocks it — sometimes for a few hundred milliseconds, sometimes longer on older hardware — and the user watches your UI freeze on launch. The fix is a dedicated serial queue that owns the session for its entire life. Every addInput, addOutput, startRunning, and stopRunning hops onto that queue.
The second mistake is mutating the graph without telling it. Any time you add or remove inputs and outputs, or change a connection, you wrap the work in beginConfiguration() / commitConfiguration(). The session batches the changes and applies them atomically; skip the brackets and you get a torn configuration where, say, the input is attached but the connection hasn't settled, and the first frame arrives rotated wrong or not at all.
private let sessionQueue = DispatchQueue(label: "camera.session")
private let session = AVCaptureSession()
func configure() {
sessionQueue.async { [weak self] in
guard let self else { return }
session.beginConfiguration()
session.sessionPreset = .high
guard
let device = AVCaptureDevice.default(.builtInWideAngleCamera,
for: .video, position: .back),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input)
else {
session.commitConfiguration() // always balance the begin
return
}
session.addInput(input)
let output = AVCaptureVideoDataOutput()
if session.canAddOutput(output) { session.addOutput(output) }
session.commitConfiguration()
session.startRunning() // safe here — we're off main
}
}
Note the canAddInput / canAddOutput guards. On a real device these can return false for reasons the simulator never surfaces — the front camera doesn't support your preset, another app grabbed the hardware, the device is mid-thermal-shutdown. Check them, don't assume.
Orientation is a connection property now, not a device one
For years the answer to "why is my video sideways" was AVCaptureConnection.videoOrientation, mapped from the UIDevice or interface orientation by hand. That property is deprecated. The modern replacement is videoRotationAngle — a plain rotation in degrees (0, 90, 180, 270) that you set on the connection. It's clearer, but it moves the burden onto you: you have to decide the angle and react to rotation yourself.
The clean way to get the angle is AVCaptureDevice.RotationCoordinator. You hand it the device and the preview layer, and it publishes the correct angle for capture and for preview separately — they differ, which is exactly the subtlety people get wrong by hand. Observe its videoRotationAngleForHorizonLevelCapture and push the value to your output connection whenever it changes.
The preview layer and the photo/video output want different rotation angles, and applying the preview angle to your saved file is the classic "looks right on screen, saves rotated" bug. Use the coordinator's two separate properties — one for the layer, one for the output connection — rather than reusing a single angle for both.
Interruptions are normal, not exceptional
This is the category that bites hardest, because the simulator will never generate a single interruption. On a real phone they're constant: a call comes in, the user pulls down Control Center, a FaceTime request lands, Siri activates, the app slides into Split View on iPad, or it simply goes to the background. Each of these can suspend your session. iOS posts AVCaptureSession.wasInterruptedNotification with a reason in the userInfo, and AVCaptureSession.interruptionEndedNotification when it's over.
The bug I see most: the developer calls startRunning() in viewWillAppear and assumes the session stays running forever after. It doesn't. After an interruption ends, iOS does not always resume you automatically — and even when it does, you want to know, so you can hide your "camera unavailable" overlay. So you observe both notifications and respond. You also handle runtimeError, because a media-services reset (yes, that happens on device) tears the session down entirely and you have to rebuild it.
Authorization can change underneath you
Authorization looks simple — request access, get a yes or no — until you remember the user can walk into Settings while your app is suspended and revoke the camera permission, then swipe back. Your session is now running against a camera it's no longer allowed to use. The reasons multiply on real devices: .denied, .restricted (parental controls or an MDM profile, which you'll never hit on your own dev phone), or .notDetermined on a truly fresh install.
Always read AVCaptureDevice.authorizationStatus(for: .video) at the point you're about to configure, not once at launch. And handle the mid-session change: when the app returns to the foreground, re-check the status before assuming the preview is still valid. A denied camera mid-session manifests as frozen frames, not an exception — so you have to ask.
Thermal throttling is real, and it's silent
Run a capture session for fifteen minutes on a warm day — recording video, doing frame analysis, whatever — and the device heats up. iOS responds by throttling: the GPU clocks down, frame delivery slows, and if you're driving Metal or Vision off the video frames you'll see dropped frames and stutter long before anything crashes. The system tells you via ProcessInfo.processInfo.thermalState and the thermalStateDidChangeNotification.
The honest move is to degrade gracefully. At .serious I drop the frame rate and resolution; at .critical I stop nonessential work — pause the live filter, kill the secondary preview — and tell the user the device is hot rather than letting the app feel broken. This bug is invisible on the simulator (it has your Mac's cooling and an effectively infinite thermal budget) and invisible on a fresh, cool device in a five-minute demo. It only appears in the wild.
Wiring interruption and rotation handling together
Here's the skeleton I reuse — observers for interruptions, rotation, and thermal state, with the session work kept on its own queue. The point isn't the exact policy; it's that none of these handlers exist by default, and each one closes a gap the simulator hid from you.
private let coordinator: AVCaptureDevice.RotationCoordinator
func startObserving(output: AVCaptureVideoDataOutput) {
let nc = NotificationCenter.default
// Session was suspended (call, Control Center, backgrounding, Split View).
nc.addObserver(forName: .AVCaptureSessionWasInterrupted,
object: session, queue: .main) { note in
let reason = note.userInfo?[AVCaptureSessionInterruptionReasonKey]
print("interrupted:", reason ?? "unknown") // show an overlay here
}
// Interruption ended — resume on the session queue, not main.
nc.addObserver(forName: .AVCaptureSessionInterruptionEnded,
object: session, queue: .main) { [weak self] _ in
self?.sessionQueue.async {
guard let self, !session.isRunning else { return }
session.startRunning()
}
}
// Media services reset / hardware error: rebuild from scratch.
nc.addObserver(forName: .AVCaptureSessionRuntimeError,
object: session, queue: .main) { [weak self] note in
let err = note.userInfo?[AVCaptureSessionErrorKey] as? AVError
if err?.code == .mediaServicesWereReset { self?.configure() }
}
// Rotation: keep the output connection's angle current.
coordinator.observe(\.videoRotationAngleForHorizonLevelCapture,
options: [.initial, .new]) { _, change in
guard let angle = change.newValue,
let conn = output.connection(with: .video),
conn.isVideoRotationAngleSupported(angle) else { return }
conn.videoRotationAngle = angle
}
// Thermal pressure: back off before the device forces it.
nc.addObserver(forName: ProcessInfo.thermalStateDidChangeNotification,
object: nil, queue: .main) { _ in
if ProcessInfo.processInfo.thermalState == .critical {
// pause filters, drop frame rate, tell the user it's hot
}
}
}
If there's one habit that's saved me more than any code snippet, it's this: test on the oldest device you still support, outdoors, on a hot day. That sentence covers almost everything the simulator won't. The old device exposes the thermal and main-thread-blocking problems, the outdoors exposes the real authorization and interruption flows you skip through on your desk, and the heat surfaces the throttling that fifteen minutes of recording will eventually trigger. The simulator is a fine place to write a camera feature. It is a terrible place to believe one works.