Swift

Swift Concurrency: Async/Await in Real Projects

By Seren  |  25 Sep, 2026  |  Leave a comment


My first useful Swift concurrency refactor was not a large rewrite. It was a dashboard screen that loaded a profile, notifications, and small labels for each notification. The callback version worked, but it mixed request coordination, error handling, shared cache updates, and UI delivery in one difficult-to-review function.

I rebuilt that flow as a small structured-concurrency pipeline. The result was easier to reason about because each child task had an owner, failures stopped the incomplete load, and the cache could be isolated behind an actor. The downloadable project recreates that scenario with deterministic fixture services rather than a live API.

The Dashboard Is a Coordination Problem

The sample dashboard needs a user profile and a list of notifications. Those two operations do not depend on each other, so waiting for the first before starting the second adds unnecessary ordering. After both finish, the loader creates a label for every notification and stores one complete dashboard in a cache.

The important requirement is that partial data must not be cached. If notifications fail, a newly loaded profile alone is not a valid dashboard. This is the sort of rule that becomes hard to see when several callbacks update shared variables and a counter decides when the work is finished.

Async/await makes the steps read in their execution order, but readable syntax is only part of the improvement. Structured concurrency also gives child tasks a lifetime connected to the parent operation. That relationship affects error propagation and cancellation, which matters more than simply removing indentation.

A Small Async Service Keeps the Example Reproducible

The project uses a fixture service with short sleeps to represent asynchronous work. It returns fixed values and can be told to fail at a named endpoint. No API key or network connection is required, so another developer can run the same success, failure, and cancellation checks.

func profile() async throws -> Profile {
    try await pause(endpoint: "profile", nanoseconds: 80_000_000)
    return Profile(name: "Maya")
}

Task.sleep suspends the task for the requested interval; it is not a synchronous sleep that intentionally occupies a thread. The helper checks cancellation before sleeping, and the sleep itself can throw when the task is cancelled. A real service could replace this method with a URLSession request while preserving the loader’s async interface.

I prefer examples like this over code that points to an imaginary API host. A fixture cannot prove server behavior, but it can prove the coordination rules in the client and makes each failure path easy to trigger.

Use async let for a Fixed Set of Independent Values

The loader knows at compile time that it needs exactly two independent values. This is a good fit for async let:

async let profile = service.profile()
async let notifications = service.notifications()

let (loadedProfile, loadedNotifications) =
    try await (profile, notifications)

Each binding begins child work in the current scope. The tuple await collects both values before the next stage. If one child throws, the loader does not continue as though it had a complete response. The scope manages unfinished child work as it exits.

I do not use async let merely because two functions are asynchronous. If the notification request needs an identifier returned by the profile request, the dependency is real and the calls should remain sequential. Concurrency helps only when the operations can safely overlap.

Use a Task Group When the Number of Tasks Comes from Data

After notifications arrive, the loader needs one badge text for each item. The number of items is known only at runtime, so a throwing task group is a better fit:

try await withThrowingTaskGroup(of: String.self) { group in
    for item in items {
        group.addTask {
            try await service.badge(for: item)
        }
    }

    var badges: [String] = []
    for try await badge in group {
        badges.append(badge)
    }
    return badges
}

Results arrive in completion order, not necessarily input order. The sample sorts the finished strings before building the dashboard because its expected display order is deterministic. In a real screen I would usually carry the source index or item ID through each child task and restore the intended order explicitly.

A task group does not automatically make an unbounded amount of work appropriate. If the input can contain hundreds of downloads, I would add a concurrency limit or process batches. Server limits, memory use, and cancellation behavior still need deliberate design.

Keep Shared Mutable State Behind an Actor

The dashboard cache is shared by asynchronous callers. Instead of allowing those callers to read and write stored properties directly, the example uses an actor:

actor DashboardCache {
    private var value: Dashboard?
    private var writeCount = 0

    func store(_ dashboard: Dashboard) {
        value = dashboard
        writeCount += 1
    }
}

Actor isolation controls access to the mutable properties. Code outside the actor uses await when crossing that boundary. This removes the need for callers to remember which private queue or lock protects the cache.

An actor is not a promise that every higher-level operation is automatically atomic. If code reads a value, awaits unrelated work, and later writes based on the old value, another actor call may run during that suspension. Operations that must stay consistent should be expressed as one actor method or designed with the possible reentrancy in mind.

Cache Only After the Whole Load Succeeds

The loader constructs the dashboard only after the profile, notifications, and all badges have succeeded. The cache write is the final step:

let dashboard = Dashboard(
    profile: loadedProfile,
    notifications: loadedNotifications,
    badgeTexts: badges.sorted()
)
await cache.store(dashboard)
return dashboard

This ordering makes the partial-data rule visible. The executable configures a second fixture service to fail while loading notifications and then checks that the cache remains empty. There is no cleanup callback and no temporary cache entry to remove.

Some products intentionally show stale or partial data. In that case I would model that policy with an explicit state rather than silently saving whatever completed first. Structured concurrency does not choose the product behavior; it gives the behavior a clearer place in the code.

Cancellation Has to Reach the Work That Matters

Cancellation in Swift is cooperative. Calling cancel() records the request, but code must encounter a cancellation-aware suspension or inspect the task state. The fixture helper calls Task.checkCancellation(), and Task.sleep also responds to cancellation.

The executable starts a profile task, cancels it, and verifies that awaiting its value throws CancellationError. That small check prevents the example from claiming cancellation support without exercising it.

CPU-heavy loops and wrappers around older callback APIs need extra attention because they may not naturally suspend. I add periodic cancellation checks where abandoning work is useful, and I make sure a continuation is resumed exactly once when bridging a completion handler.

Where MainActor Belongs in an App

A SwiftUI or UIKit view model that owns presentation state is often a sensible place for @MainActor. The network and decoding service usually does not need main-actor isolation. Keeping those responsibilities separate avoids making every caller cross the main actor just to fetch or transform data.

@MainActor
final class DashboardViewModel {
    private(set) var state: ViewState = .idle

    func refresh(using loader: DashboardLoader) async {
        state = .loading
        do {
            state = .content(try await loader.load())
        } catch is CancellationError {
            state = .idle
        } catch {
            state = .failed
        }
    }
}

The command-line package does not need a UI, so it keeps this view-model sketch in the article and concentrates the runnable checks on coordination. In an application, actor annotations should describe ownership, not be scattered around as substitutes for understanding which state belongs to the interface.

Checks I Make During a Concurrency Refactor

  • Are concurrent operations genuinely independent?
  • Who owns each child task and when should it end?
  • What happens to sibling work when one child throws?
  • Does result order matter to the caller?
  • Can the input create too many simultaneous tasks?
  • Which mutable state needs actor isolation?
  • Can an actor method suspend between a read and a related write?
  • Does cancellation reach network, sleep, or CPU-heavy work?
  • Is partial data valid, or should the entire operation fail?

The dashboard refactor helped because it made these questions explicit. Async/await improved the surface syntax, while task structure, failure boundaries, actor isolation, and cancellation determined whether the feature behaved correctly.

Download and Run the Example

The complete example is a Swift Package for macOS and requires Swift 5.9 or later. It uses Foundation and local fixtures, with no third-party dependencies or credentials.

git clone https://github.com/2252408699/swift-concurrency-dashboard-loader.git
cd swift-concurrency-dashboard-loader
swift run

A successful run prints eight PASS lines followed by All concurrency checks passed. The checks cover successful composition, task-group output, actor cache state, propagated failure, prevention of partial caching, and cancellation.

Complete source code: swift-concurrency-dashboard-loader on GitHub

Seren
Seren

A developer exploring iOS, Objective-C, Swift, and other tech stacks. Here to document my learning process, pitfalls, and growing journey across new technologies.

Your email address will not be published. Required fields are marked *