Launch A Camera App Quickly With Deferred Start

Camera launch performance is mostly judged before your app feels “ready.”

If someone opens a camera to catch a kid jumping into a pool, a skateboard trick, or a receipt before it blows away, they are not waiting for your mode picker, thumbnail well, ML overlay, beauty filter, histogram, or recording pipeline. They are waiting for the first real preview frame. Every millisecond before that frame is part of the blank-screen tax.

That is the useful mental model behind Apple’s WWDC26 session “Build a responsive camera app that launches quickly”: treat launch as a race to the first preview frame, then bring the rest of the camera online without stealing responsiveness back.

The API names below are new and beta-shaped. Apple’s sample documentation says it requires Xcode 27, iOS or iPadOS 27 hardware, and cannot use Simulator for camera access. The deferred start symbols are documented as iOS 26 and later, while the sample project targets the newer toolchain and OS hardware. Check the exact availability annotations, symbol spellings, and behavior in the Xcode seed you are using before shipping.

Split the launch

A fast camera launch starts by being a little ruthless about what counts as startup work.

Preview-critical work:

  • Create the minimum UI needed to hold preview.
  • Create and configure the AVCaptureSession.
  • Add the camera input.
  • Add or attach the output used to render preview.
  • Start the session off the main thread.
  • Show a shutter affordance once capture can be accepted.

Everything else can usually wait:

  • AVCapturePhotoOutput setup that is not needed for preview.
  • Movie outputs.
  • Thumbnail wells.
  • Mode pickers.
  • Filters, histograms, overlays, and analysis pipelines.
  • Library queries.
  • Expensive GPU, CPU, or Apple Neural Engine work.
  • Settings panels and non-critical chrome.

This does not mean the rest of the camera is unimportant. It means the first preview frame has a higher priority than the rest of the interface. Let people see the world through the camera first, then fill in the secondary tools.

Keep session work off the main thread

AVCaptureSession setup is not a main-thread job. Creating and configuring a capture session can block. startRunning() and stopRunning() are blocking calls too, so calling either one on the main thread is a launch hitch waiting to happen.

A common shape is still a dedicated session queue. Build your SwiftUI or UIKit shell on the main thread, attach the preview layer to the view, and let the capture queue do the blocking work.

import AVFoundation
import UIKit

@available(iOS 26.0, *)
final class CameraPreviewView: UIView {
    override class var layerClass: AnyClass {
        AVCaptureVideoPreviewLayer.self
    }

    var previewLayer: AVCaptureVideoPreviewLayer {
        layer as! AVCaptureVideoPreviewLayer
    }

    func attach(session: AVCaptureSession) {
        previewLayer.session = session
        previewLayer.videoGravity = .resizeAspectFill

        // The preview layer is launch-critical. Do not defer it.
        previewLayer.isDeferredStartEnabled = false
    }
}

The preview view can be created as part of your first camera screen. The session controller owns the AVFoundation state and starts it on its queue.

import AVFoundation

@available(iOS 26.0, *)
final class FastCameraController {
    enum CameraError: Error {
        case missingCamera
        case missingSession
        case cannotAddInput
        case cannotAddPhotoOutput
        case hardwareCostTooHigh(Double)
    }

    private let sessionQueue = DispatchQueue(
        label: "com.example.camera.session",
        qos: .userInitiated
    )
    private let deferredCallbackQueue = DispatchQueue(
        label: "com.example.camera.deferred-start"
    )

    private let deferredStartObserver = DeferredStartObserver()
    private var session: AVCaptureSession?
    private var photoOutput: AVCapturePhotoOutput?

    func prepare(completion: @escaping (Result<AVCaptureSession, Error>) -> Void) {
        sessionQueue.async { [self] in
            do {
                if let session {
                    DispatchQueue.main.async {
                        completion(.success(session))
                    }
                    return
                }

                let session = AVCaptureSession()
                let photoOutput = AVCapturePhotoOutput()

                try configure(session: session, photoOutput: photoOutput)

                self.session = session
                self.photoOutput = photoOutput

                DispatchQueue.main.async {
                    completion(.success(session))
                }
            } catch {
                DispatchQueue.main.async {
                    completion(.failure(error))
                }
            }
        }
    }

    func startRunning(completion: @escaping (Result<Void, Error>) -> Void) {
        sessionQueue.async { [self] in
            do {
                guard let session else {
                    throw CameraError.missingSession
                }

                guard session.hardwareCost <= 1.0 else {
                    throw CameraError.hardwareCostTooHigh(session.hardwareCost)
                }

                session.startRunning()

                DispatchQueue.main.async {
                    completion(.success(()))
                }
            } catch {
                DispatchQueue.main.async {
                    completion(.failure(error))
                }
            }
        }
    }

    func stop() {
        sessionQueue.async { [self] in
            if let session, session.isRunning {
                session.stopRunning()
            }
        }
    }

    private func configure(
        session: AVCaptureSession,
        photoOutput: AVCapturePhotoOutput
    ) throws {
        session.beginConfiguration()
        defer { session.commitConfiguration() }

        session.sessionPreset = .photo

        // Apple's transcript says automatic deferred start is the default
        // for apps rebuilt against the iOS 26+ SDK. Set it explicitly while
        // adopting the feature so the launch behavior is obvious in code.
        session.automaticallyRunsDeferredStart = true

        guard let camera = AVCaptureDevice.default(
            .builtInWideAngleCamera,
            for: .video,
            position: .back
        ) else {
            throw CameraError.missingCamera
        }

        let input = try AVCaptureDeviceInput(device: camera)
        guard session.canAddInput(input) else {
            throw CameraError.cannotAddInput
        }
        session.addInput(input)

        guard session.canAddOutput(photoOutput) else {
            throw CameraError.cannotAddPhotoOutput
        }
        session.addOutput(photoOutput)

        // The photo output is not required for the first preview frame.
        if photoOutput.isDeferredStartSupported {
            photoOutput.isDeferredStartEnabled = true
        }

        // Keep the shutter useful while the deferred output is coming online.
        photoOutput.isResponsiveCaptureEnabled =
            photoOutput.isResponsiveCaptureSupported

        session.setDeferredStartDelegate(
            deferredStartObserver,
            deferredStartDelegateCallbackQueue: deferredCallbackQueue
        )
    }

    func capturePhoto(delegate: AVCapturePhotoCaptureDelegate) {
        sessionQueue.async { [self] in
            guard let photoOutput else { return }
            let settings = AVCapturePhotoSettings()
            photoOutput.capturePhoto(with: settings, delegate: delegate)
        }
    }
}

A view controller would wire those two pieces together in this order:

@available(iOS 26.0, *)
final class CameraViewController: UIViewController {
    private let camera = FastCameraController()
    private let previewView = CameraPreviewView()

    override func loadView() {
        view = previewView
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        camera.prepare { [previewView, camera] result in
            switch result {
            case .success(let session):
                previewView.attach(session: session)
                camera.startRunning { result in
                    if case .failure(let error) = result {
                        print(error)
                    }
                }
            case .failure(let error):
                // Show a camera-specific recovery path.
                print(error)
            }
        }
    }
}

The key point is not the exact UIKit shell. It is the separation: main thread creates and attaches the preview surface, while the capture queue configures and starts the session.

Defer every non-preview output

Deferred start lets the session postpone expensive output initialization until after preview has a chance to render. Each AVCaptureOutput and AVCaptureVideoPreviewLayer has isDeferredStartEnabled.

For launch, the rule is simple:

  • The output or layer that renders preview should not be deferred.
  • Outputs that are not needed for preview should be deferred.

That usually means AVCaptureVideoPreviewLayer.isDeferredStartEnabled = false, while supported AVCapturePhotoOutput, movie outputs, and analysis outputs start with isDeferredStartEnabled = true. Check isDeferredStartSupported first. Apple’s documentation says setting isDeferredStartEnabled to true on an unsupported output throws an invalid-argument exception.

Automatic mode is the easiest path. Set captureSession.automaticallyRunsDeferredStart = true, or rely on the default for apps rebuilt against the iOS 26 and later SDKs after verifying that behavior in your toolchain. In automatic mode, the system decides when to initialize deferred outputs after preview appears.

The delegate callbacks are useful for UI coordination and measurement:

import AVFoundation

@available(iOS 26.0, *)
final class DeferredStartObserver: NSObject, AVCaptureSessionDeferredStartDelegate {
    func sessionWillRunDeferredStart(_ session: AVCaptureSession) {
        // Deferred outputs are about to initialize.
        // Avoid starting your own expensive GPU or ML work at the same moment.
    }

    func sessionDidRunDeferredStart(_ session: AVCaptureSession) {
        // Deferred outputs are initialized.
        // It is now safer to enable advanced modes that require those outputs.
    }
}

Do not use sessionDidRunDeferredStart(_:) as the moment when the camera becomes visually ready. That callback is about the deferred outputs. The user-facing launch moment is still the first preview frame.

Keep capture responsive

Deferring AVCapturePhotoOutput improves preview launch time, but it also creates a product problem: the user may tap the shutter before the photo output has fully initialized.

That is what responsive capture is for.

photoOutput.isResponsiveCaptureEnabled =
    photoOutput.isResponsiveCaptureSupported

With responsive capture enabled, the app can accept a photo request even while the photo pipeline is still getting ready. The important design choice is to keep the shutter path lightweight. Do not make the shutter button wait for your thumbnail well, mode picker, editor state, or non-critical output setup.

In practice, I would model the first few seconds of a camera screen like this:

  1. App opens and creates the preview surface.
  2. Session starts on the capture queue.
  3. Preview appears.
  4. Shutter is usable, with responsive capture enabled when supported.
  5. Deferred outputs finish.
  6. Secondary UI fades in and advanced modes become available.

If isResponsiveCaptureSupported is false, be honest in your fallback. Either enable the shutter when the photo output is ready, or accept the tap and present a clear in-camera busy state. Do not silently drop the first tap.

Use manual deferred start when you own preview frames

AVCaptureVideoPreviewLayer is the best preview renderer for many apps because it is optimized for low-latency camera display and avoids per-frame processing overhead.

If your app renders preview through AVCaptureVideoDataOutput, the preview output itself is launch-critical. Do not defer that output. Instead, use manual deferred start and call runDeferredStartWhenNeeded() after your first frame is actually presented and any immediate startup work is done.

Manual mode is conditional too. Check captureSession.isManualDeferredStartSupported before setting automaticallyRunsDeferredStart to false.

import AVFoundation

@available(iOS 26.0, *)
final class ManualDeferredStartCamera {
    enum CameraError: Error {
        case manualDeferredStartUnsupported
    }

    private let sessionQueue = DispatchQueue(
        label: "com.example.camera.manual-deferred",
        qos: .userInitiated
    )

    private var session: AVCaptureSession?

    func configure(completion: @escaping (Result<AVCaptureSession, Error>) -> Void) {
        sessionQueue.async { [self] in
            do {
                let session = AVCaptureSession()
                let previewOutput = AVCaptureVideoDataOutput()
                let photoOutput = AVCapturePhotoOutput()

                try configure(
                    session: session,
                    previewOutput: previewOutput,
                    photoOutput: photoOutput
                )

                self.session = session

                DispatchQueue.main.async {
                    completion(.success(session))
                }
            } catch {
                DispatchQueue.main.async {
                    completion(.failure(error))
                }
            }
        }
    }

    private func configure(
        session: AVCaptureSession,
        previewOutput: AVCaptureVideoDataOutput,
        photoOutput: AVCapturePhotoOutput
    ) throws {
        session.beginConfiguration()
        defer { session.commitConfiguration() }

        guard session.isManualDeferredStartSupported else {
            throw CameraError.manualDeferredStartUnsupported
        }

        session.automaticallyRunsDeferredStart = false

        // This output is your preview path, so it must be ready for launch.
        previewOutput.isDeferredStartEnabled = false

        // This can wait until preview is visible.
        if photoOutput.isDeferredStartSupported {
            photoOutput.isDeferredStartEnabled = true
        }

        if session.canAddOutput(previewOutput) {
            session.addOutput(previewOutput)
        }

        if session.canAddOutput(photoOutput) {
            session.addOutput(photoOutput)
        }
    }

    func firstPreviewFrameDidPresent() {
        sessionQueue.async { [self] in
            session?.runDeferredStartWhenNeeded()
        }
    }
}

That last method should be called from your rendering path, not from startRunning(). For example, call it after your Metal view, sample-buffer display layer, or custom compositor has presented the first frame. AVCaptureSession becoming “running” is a useful phase marker, but it is not proof that pixels reached the screen.

Budget for pressure

Launch speed is only half the experience. A camera that opens quickly and then drops preview frames under heat still feels broken.

Start with the configuration cost. After committing the session, check captureSession.hardwareCost <= 1.0. A value above 1.0 means the configuration is beyond what the system can support. That is a design signal, not a warning to ignore.

Then observe device.systemPressureState and adapt before the device forces a worse outcome.

import AVFoundation

final class CameraPressureMonitor {
    private let device: AVCaptureDevice
    private let queue: DispatchQueue
    private var observation: NSKeyValueObservation?

    init(device: AVCaptureDevice, queue: DispatchQueue) {
        self.device = device
        self.queue = queue

        observation = device.observe(
            \.systemPressureState,
            options: [.initial, .new]
        ) { [weak self] device, _ in
            self?.queue.async {
                self?.adapt(to: device.systemPressureState)
            }
        }
    }

    private func adapt(to pressure: AVCaptureDevice.SystemPressureState) {
        switch pressure.level {
        case .nominal, .fair:
            break
        case .serious, .critical:
            reduceCameraLoad()
            reduceAppLoad()
        case .shutdown:
            reduceAppLoad()
        @unknown default:
            reduceAppLoad()
        }
    }

    private func reduceCameraLoad() {
        do {
            try device.lockForConfiguration()
            device.activeVideoMinFrameDuration = CMTime(value: 1, timescale: 30)
            device.activeVideoMaxFrameDuration = CMTime(value: 1, timescale: 30)
            device.unlockForConfiguration()
        } catch {
            // Keep preview alive. Log and try a cheaper app-side fallback.
        }
    }

    private func reduceAppLoad() {
        // Pause non-critical ML analysis, lower overlay cadence,
        // skip cosmetic effects, or postpone thumbnail work.
    }
}

The best pressure response is product-specific. A document scanner might lower overlay cadence before lowering capture quality. A social camera might pause live effects first. A video app may need to reduce frame rate or resolution before the device reaches a critical pressure level.

The shared rule is that pressure handling must protect preview cadence and capture reliability. That usually means expensive app work backs off before the core camera path does.

Test the thing users feel

Do not test camera launch only by staring at Xcode logs. Logs are useful, but the user notices pixels.

Measure these separately:

  • App launch to first camera screen.
  • First camera screen to AVCaptureSession.startRunning().
  • startRunning() duration.
  • First preview frame on screen.
  • Shutter tap accepted while deferred outputs are still initializing.
  • sessionWillRunDeferredStart(_:) to sessionDidRunDeferredStart(_:).
  • Preview frame drops during and after deferred start.
  • System pressure transitions during warm and hot runs.

For AVCaptureVideoPreviewLayer, there is no convenient per-frame callback like a video data output gives you. Use signposts for your own phases, then validate first visible preview with real-device screen recording, external high-speed video, or a repeatable scene with movement. Apple’s session demonstrates the same idea with a light pattern: a visible scene makes launch latency obvious.

For custom preview rendering, add a first-frame signpost at the point your renderer actually presents the first camera frame:

import os

let cameraLaunchLog = OSLog(
    subsystem: "com.example.camera",
    category: "Launch"
)

os_signpost(.begin, log: cameraLaunchLog, name: "CameraLaunch")

// After the first preview frame is displayed:
os_signpost(.event, log: cameraLaunchLog, name: "FirstPreviewFramePresented")

// After secondary UI and deferred outputs are ready:
os_signpost(.end, log: cameraLaunchLog, name: "CameraLaunch")

Run those tests on real hardware. The Apple sample documentation calls out Xcode 27, iOS or iPadOS 27 hardware, and no Simulator camera access. That matters here because the simulator cannot reproduce camera startup, sensor pressure, ISP behavior, device thermals, or capture output initialization cost.

A useful test matrix is small but real:

  • Cold launch after force quit.
  • Warm launch after returning from another app.
  • Reopen after locking and unlocking the device.
  • First shutter tap immediately after preview appears.
  • 10 to 20 repeated launches in a row.
  • Launch while the device is warm.
  • Launch with Low Power Mode enabled.
  • Launch with your most expensive optional outputs enabled.
  • Launch with those optional outputs disabled.

The most valuable result is not one perfect number. It is knowing which phase owns the delay and whether the user can still capture the moment while the rest of the camera catches up.

AVProVideoStorage is worth knowing about if your app records ProRes or other high-data-rate video. It is aimed at deterministic file writing for demanding recording workflows, not at first-preview-frame launch latency.

Keep it separate in your architecture. Deferred start helps preview appear sooner. Responsive capture helps photo requests stay useful during deferred initialization. Pressure handling keeps the camera sustainable. AVProVideoStorage is for the storage side of serious video capture.

Shipping checklist

Before you ship a deferred-start camera flow:

  • Attach the preview layer early and keep AVCaptureVideoPreviewLayer.isDeferredStartEnabled false.
  • Configure AVCaptureSession on a dedicated queue.
  • Call startRunning() and stopRunning() off the main thread.
  • Commit one focused launch configuration instead of repeatedly reconfiguring during startup.
  • Set captureSession.automaticallyRunsDeferredStart = true for automatic deferred start, unless you intentionally need manual control.
  • Set isDeferredStartEnabled = true on every supported output that is not required for preview.
  • Use AVCaptureSessionDeferredStartDelegate to coordinate secondary UI and expensive app work.
  • Enable responsive capture with photoOutput.isResponsiveCaptureEnabled = photoOutput.isResponsiveCaptureSupported.
  • For video-data-output preview, check isManualDeferredStartSupported, then use manual mode and call runDeferredStartWhenNeeded() after the first presented frame.
  • Check captureSession.hardwareCost <= 1.0 after configuration.
  • Observe device.systemPressureState and reduce frame rate, GPU work, ANE work, or UI work as pressure rises.
  • Test first preview frame and first shutter tap on real hardware, not Simulator.

The goal is not to make the camera do less. It is to put the work in the right order. Show preview first, accept the shot quickly, then initialize the rest of the capture stack when it will not cost the user the moment they opened the app to catch.

Sources