← all posts

A photo timer that does four things at once (and cancels cleanly)

Structured concurrency for a real workflow: a 5-second countdown that runs alongside three data-fetching tasks, gathers their return values, and unwinds cleanly on cancel — with one Task, one @MainActor, and one [weak self].

The camera's photo timer looks trivial from the outside: five, four, three, two, one, shutter. The interesting parts are the ones the user never sees. Before the shutter fires, real cameras also want to lock focus, meter exposure, warm up HDR, sometimes fetch location for the geotag, sometimes run an on-device ML pass to pick a filter. All of that has to finish before the count hits zero, otherwise the user waits.

Meanwhile the user has to be able to tap Cancel at any moment and have everything stop. Not the countdown paused while the network request keeps draining battery — everything.

This is a piece about that shape, using Swift's structured concurrency. Four child tasks under one parent. One Task? handle on the view model. Cancellation is one line and it does the right thing everywhere.

A SwiftUI photo timer: idle screen becomes a big countdown from 5 with a checklist of Camera ready / Location tagged / Filter chosen ticking on at different moments, then a captured screen showing camera settings, location, and filter. Tapping Cancel mid-countdown returns to idle immediately.

Four child tasks running at once: the visible countdown, plus camera prep, location fetch, and filter suggestion — each ticking on as it lands.

Full source on GitHub: Olya-Yer/async-countdown.

What runs, and when

The user sees the countdown tick from 5 to 1. Behind it, three things are happening in parallel. Each returns a typed value that the final "captured photo" screen wants to display:

struct CameraSettings: Equatable {
    let aperture: String       // "f/2.8"
    let shutterSpeed: String   // "1/60s"
    let iso: Int               // 400
}

struct LocationTag: Equatable {
    let name: String           // "Golden Gate Park"
}

// filter suggestion is just a String: "Vivid"

Sequentially it takes about nine seconds of wall clock. Concurrently it fits inside the five the user is already waiting through.

async let: fork and join

The whole workflow is one method:

private func run() async {
    prep = Prep()
    status = .counting(remaining: countdownSeconds)

    async let countdown: Void            = runCountdown()
    async let settings:  CameraSettings  = prepareCamera()
    async let location:  LocationTag     = fetchLocation()
    async let filter:    String          = suggestFilter()

    do {
        let (_, cameraSettings, locationTag, filterName) =
            try await (countdown, settings, location, filter)

        status = .captured(CapturedPhoto(
            settings: cameraSettings,
            location: locationTag,
            filter: filterName
        ))
    } catch is CancellationError {
        prep = Prep()
        status = .idle
    } catch {
        prep = Prep()
        status = .idle
    }
}

Each async let starts a child task. They run concurrently under the same parent (this method's own Task). The try await (a, b, c, d) line suspends until all four have finished, then hands you back a tuple of their return values. Tuple destructuring gives them their names.

You get three properties out of this for free:

  1. Fork-join without bookkeeping. No manual TaskGroup, no completion counters. The compiler enforces that every async let gets awaited.
  2. Cancellation propagates. If the enclosing Task is cancelled, all four children are cancelled. Each one throws CancellationError from its Task.sleep, the try await re-throws, and we land in the catch.
  3. Errors compose. If any child throws, the whole tuple await unwinds — you don't have to inspect individual results to decide whether the workflow failed.

The children themselves are unremarkable:

private func runCountdown() async throws {
    for value in stride(from: countdownSeconds, through: 1, by: -1) {
        status = .counting(remaining: value)
        try await Task.sleep(for: .seconds(1))
    }
}

private func prepareCamera() async throws -> CameraSettings {
    try await Task.sleep(for: .seconds(1.4))
    prep.cameraReady = true
    return CameraSettings(aperture: "f/2.8", shutterSpeed: "1/60s", iso: 400)
}

private func fetchLocation() async throws -> LocationTag {
    try await Task.sleep(for: .seconds(2.8))
    prep.locationTagged = true
    return LocationTag(name: "Golden Gate Park")
}

private func suggestFilter() async throws -> String {
    try await Task.sleep(for: .seconds(4.2))
    prep.filterChosen = true
    return "Vivid"
}

Task.sleep(for:) is a placeholder for a network call or an AVCaptureDevice handshake — the shape is what matters. What isn't placeholder is that each one returns something. The countdown returns Void, but the other three return real data that the caller uses to build the final CapturedPhoto.

One Task?, one .cancel()

Cancellation lives on the view model as one stored handle:

private var timerTask: Task<Void, Never>?

func startTimer() {
    timerTask?.cancel()          // kill any previous run
    timerTask = Task { [weak self] in
        guard let self else { return }
        await self.run()
    }
}

func cancel() {
    timerTask?.cancel()
}

That's the whole cancellation story. The user taps Cancel; the view calls vm.cancel(); timerTask?.cancel() runs. Because run() is executing inside that Task, and the four async let children inherit its cancellation scope, the very next Task.sleep in any of them throws CancellationError. The try await (…) re-throws, the do unwinds, the catch is CancellationError branch fires, prep resets, status goes back to .idle.

The pattern I keep coming back to: one Task to cancel them all — handled by the enclosing scope, not by every leaf.

The retain-cycle trap

There is one small hazard in the setup above and it's worth naming.

The view model owns timerTask. The Task's closure needs self to call self.run(). If the closure captures self strongly, you get:

self ──owns──► timerTask ──owns──► closure ──captures──► self

A classic cycle. The view model never deallocates, the Task never deallocates, everything they reference sticks around. In a SwiftUI view model that lives for a screen, that memory eventually goes away when the scene ends — but on the way there you can leak network requests, camera handles, whatever the abandoned Task is still holding.

The [weak self] in guard let self else { return } pair breaks the loop:

timerTask = Task { [weak self] in
    guard let self else { return }
    await self.run()
}

Now the arrow from the closure back to self is weak, so the closure can outlive self — if the view model deallocates while the countdown is running, the guard exits and the child tasks are dropped on the floor. In practice the deinit typically cancels the task first, but the weak capture is the belt-and-braces version.

@MainActor: thread safety by construction

The whole view model is marked @MainActor:

@MainActor
final class PhotoTimerViewModel: ObservableObject {
    @Published private(set) var status: Status = .idle
    @Published private(set) var prep = Prep()
    // ...
}

That decoration ripples outward in useful ways:

This is the "thread safety" that async/await is actually selling: not "lockless data structures," but isolation you can read off the class declaration. If a method is @MainActor and it reads or writes state, that state is safe. No @property queue, no NSLock, no serial dispatch queue named com.mycompany.state.

What I'd think about next

A few honest limits of this exact shape:

Why I keep coming back to this shape

The reason async let and one stored Task? feel so tidy is the same reason the sticky-headers pattern in an earlier post did: the invariant is small and easy to name. Here it's everything downstream of this Task is cancellable in one call. Once that's true, the rest — parallel execution, gathered return values, main-thread safety, no cycles — falls out of patterns Swift already gives you.


Code: Olya-Yer/async-countdown on GitHub — the whole workflow is under 150 lines in PhotoTimerViewModel.swift.