Mobile & Machine

Async Await Structured Concurrency Patterns for iOS Data Layers

Swift 6 moves concurrency safety from hope to compile-time enforcement in the data layer.

Senior Writer · · 13 min read
Cover illustration for “Async Await Structured Concurrency Patterns for iOS Data Layers”
Swift Architecture · September 26, 2026 · 13 min read · 2,885 words

Concurrency pressure in an iOS app doesn't distribute evenly across the codebase. It pools in the data layer. That's where simultaneous API calls, cache reads, disk writes, and real-time socket updates all converge on the same shared state, often at the same moment, often from different threads. A button tap on a view is a discrete event. A repository serving three ViewModels while a background sync job writes to the same cache is a live contention problem, and it doesn't announce itself with a crash. It announces itself, if it announces itself at all, weeks later as a corrupted cache entry or a race that only reproduces on a slower device.

GCD and completion handlers carried iOS through more than a decade of this, but they were never built for the density of concurrent work modern apps now run. A completion-handler chain three or four calls deep turns into a pyramid that's hard to read and harder to reason about, and cancellation in that world is mostly theater: you can set a flag, but nothing guarantees the in-flight network call actually stops, or that the callback won't fire anyway and write into a view that's no longer on screen. The failure mode is a silent race that ships, not a compiler error. It's a silent race that ships.

Consider what a typical data layer supports today: video streaming, real-time sync, on-device model inference, payment processing, and calls to multiple third-party APIs, all while the UI is expected to hold 60 to 120 frames per second without a stutter. That's the actual runtime environment, and it's a much closer relative of a backend service mesh than of the simple network-call-then-update-UI pattern iOS engineering trained on for years.

Swift 6 shifts concurrency safety from advisory to enforced. Concurrency safety used to be advisory: you were expected to know which queue you were on, and the compiler trusted you. Swift 6's strict concurrency checking moves that enforcement to compile time. Data races that used to ship quietly, discovered only in production crash logs or Instruments traces, now fail to build. That's a serious shift in where the burden of correctness sits, and it means the data layer is exactly where the new model has to hold up, because that's where the state and the concurrency were always heaviest.

Async/await and structured concurrency are not the same thing, and the distinction is architectural

Async/await is syntax. It lets asynchronous code read top to bottom instead of nesting inside closures, and that alone is a real improvement for legibility. But syntax is not architecture, and treating the two as interchangeable is where a lot of "modernized" data layers quietly keep their old bugs.

Structured concurrency is the model beneath the syntax, and it produces task trees with defined lifetimes, parent-child relationships, cooperative cancellation that actually propagates down the tree, and cleanup that happens automatically when a scope exits. Async/await is the sentence. Structured concurrency is the grammar that gives the sentence consequences.

The distinction matters concretely in a data layer. Write a function with async and call it from a bare Task {} with no parent, and you've written readable code that still leaks. Nothing ties that task's lifetime to anything, so it runs to completion regardless of whether the screen that requested it is still around. Cancellation never reaches the network call because there's no structural link forcing it to. Shared mutable state read and written from that same orphan task can still race against everything else touching it. The syntax looks modern. The failure modes are the same ones GCD had.

The four constructs that form the data layer's concurrency foundation

Four building blocks do almost all the structural work in a Swift 6 data layer. Each solves a distinct problem, and the architecture in later sections is just these four assembled with intent.

Actors isolate mutable state behind a private executor and serialize every access to it. No locks, no dispatch queues, no manual synchronization. Access from outside the actor requires await, and the compiler enforces that requirement rather than trusting the developer to remember it.

TaskGroup runs a dynamic, variable-sized set of child tasks concurrently, all scoped to one structured block. The group doesn't return until every child task has either finished or been cancelled, so there's no dangling work left running after the function that spawned it has moved on. withThrowingTaskGroup collects results as they complete and can propagate a thrown error when it is rethrown out of the group body, which removes a category of manual bookkeeping that used to be a common source of bugs.

async let fires off a fixed, known number of independent tasks immediately, and only awaits their results at the point they're actually needed. It's the lightweight tool for a bounded fan-out: loading a user profile, their orders, and their notifications for one dashboard screen, all three requests starting at once rather than queued behind each other.

Sendable is the type-level guarantee that a value can cross an isolation boundary, say from an actor to a ViewModel, without carrying the risk of two different threads mutating it at once. It's less a tool for doing concurrent work and more the compiler's way of confirming the data moving between concurrent contexts is actually safe to move.

Actor-isolated repositories as the structural anchor of a safe data layer

The repository pattern is not new. It sits between ViewModels and data sources, whether that's a remote API, a local database, or an in-memory cache, and gives the rest of the app one contract to depend on instead of a scattering of implementation details. What Swift's actor model adds is that the abstraction becomes thread-safe by construction rather than by convention.

The pattern in practice: define a protocol, UserRepositoryProtocol for instance, with async throws method signatures. The ViewModel holds a reference to that protocol, not to a concrete type, and calls it exactly as it would any other async function. The concrete implementation is an actor. Every piece of mutable state that implementation needs, a cache dictionary, a set tracking in-flight requests, retry counters, lives inside that actor's isolation boundary, so nothing outside can touch it without going through the actor's serialized access path.

This produces a clean dependency direction: the domain layer defines what a repository must do, and the data layer provides the actor that does it. Domain code has no import of the data layer. That inversion is what makes the repository swappable in tests and resilient to changes in the underlying data source, since a mock actor or a mock struct conforming to the same protocol slots in without touching a single ViewModel.

Why an actor and not a class guarded by a serial DispatchQueue, which was the standard answer for years? Three reasons hold up under scrutiny. The compiler enforces the isolation boundary, so there's no annotation to forget and no code path that can quietly bypass the queue. Actors run on Swift's cooperative thread pool rather than claiming a dedicated thread, which uses CPU resources more efficiently under real load. And reentrancy in an actor is explicit: every suspension point (every await) is visible in the source, rather than hidden inside a queue's internal scheduling.

From the ViewModel's side, none of this mechanism is visible. It sees a protocol with async throws methods, injected at initialization. No actor hops, no awareness of the cache internals, no mutable state escaping upward into the presentation layer where it could get mutated from the wrong context.

Task-scoped fetching: tying request lifetimes to the UI scope that needs them

A Task {} launched without a structured scope has no lifetime tied to the UI that triggered it. That's the whole problem in one sentence. It runs until it completes, independent of whether the view that triggered it is still on screen. Network bandwidth gets spent on a response nobody will read, and worse, the result can arrive after the UI state it was meant to update has already moved on.

The fix is to scope the task's lifetime explicitly to the UI lifetime that needs it. For a single fetch, that means storing the task in a property and calling .cancel() on it in the corresponding cleanup path (onDisappear, deinit, or the equivalent): task = Task { await repository.fetch() }, with task?.cancel() fired when the view goes away. For a feature that needs several independent fetches at once, the cleaner tool is async let inside an actor-isolated method, or a TaskGroup scoped to the feature's own lifetime.

async let is the right primitive when the calls are genuinely independent. async let profile = fetchProfile(), async let posts = fetchPosts(), async let notifications = fetchNotifications(), all three start immediately, and a single combined try await gathers the results only when the code actually needs them. Total wall time collapses to the slowest of the three requests, which is the entire point of running them in parallel rather than in sequence. Sequential await calls are still the correct choice when one call depends on the result of another, fetching a user first and then fetching that user's specific content, for instance. Reaching for async let in that situation buys nothing, since the second call can't start until the first one resolves anyway.

TaskGroup earns its place when the fan-out is dynamic rather than fixed, fetching profiles for a list of IDs whose length isn't known at compile time, for example. The group itself is the scope: when it exits, every child task inside it has either finished or been cancelled, and there's no manual tracking array or cleanup step required to guarantee that.

Cancellation propagation: the contract the data layer must honour all the way down

Ignoring cancellation is one of the more expensive habits a data layer can carry into Swift 6, because it doesn't just waste resources, it corrupts state. A task that keeps running after its caller has cancelled it burns CPU and network for no reason, holds an actor's isolated state longer than necessary, and can write a stale result back into that state well after the original caller has moved on to something else.

Structured concurrency's cancellation model is genuinely cooperative, and genuinely structural. Cancel a parent task, and every child task in its tree is cancelled automatically, no manual propagation required. URLSession's async data methods are designed to respect cooperative cancellation, so the network layer can participate in this without additional ceremony. Custom CPU-bound work has to opt in explicitly, though: calling try Task.checkCancellation() at logical checkpoints, or testing Task.isCancelled before starting the next expensive step in a loop.

Repository actors carry a specific obligation here. If an actor holds references to in-flight requests, it needs to cancel those requests when its own task tree gets cancelled, rather than letting them run to completion in isolation. And a cancelled task should never write its result back into actor state; the correct behavior is to throw CancellationError upward and let the caller decide what happens next, not to quietly finish the write as though nothing happened.

A blanket catch {} swallows every error indiscriminately, CancellationError included. That single line breaks cooperative cancellation for the entire subtree beneath it, because the error that was supposed to signal "stop, unwind, clean up" gets absorbed and never seen again.

Sendable and safe cross-boundary data passing in a Swift 6 data layer

Under Swift 6's strict concurrency checking, passing a non-Sendable type across an actor boundary is a compile error. It's a compile error. That single change forces a design decision that used to be optional: what exactly is allowed to move between a repository actor and the ViewModel that depends on it?

The simplest and most durable answer is to make the model objects that cross that boundary value types. Structs are Sendable by default, with no additional conformance to write, as long as every property inside them is also Sendable. Designing data-layer models this way from the start avoids the entire category of problem.

Reference types sometimes have to cross the boundary anyway, and there are three legitimate ways to handle it. Mark the class final, confirm every stored property is itself Sendable, and add explicit Sendable conformance. Where a third-party type makes that impossible, @unchecked Sendable is available, but it should come with a comment documenting what invariant is being manually upheld, since the compiler is no longer checking anything at that point and the responsibility has shifted entirely onto the developer. Or, often the cleanest option, convert the reference type into a Sendable struct DTO right at the boundary: the actor keeps its mutable reference type fully inside its own isolation, and only the DTO, immutable and safe, crosses over.

For continuous data, a single request-response call isn't the right shape. AsyncStream fills that role: when a repository needs to push ongoing updates, socket events or database change notifications, to a ViewModel, AsyncStream provides a channel that's conditionally Sendable (when its Element type is Sendable) and structurally clear about ownership. The stream gets created inside the actor, yielded to from actor-isolated code, and consumed on the ViewModel side. No shared mutable state escapes the boundary at any point in that pipeline.

Bridging legacy APIs into the structured concurrency model without leaking the old model's risks

Not every dependency has caught up. URLSession wrappers written years ago, third-party SDKs, older persistence libraries, plenty of this still runs on completion handlers, and none of it is getting rewritten overnight.

withCheckedContinuation and withCheckedThrowingContinuation are the bridge. A completion-handler call gets wrapped inside a continuation, the continuation gets resumed exactly once when the callback fires, and the result is available at an await point inside otherwise fully structured code. The "checked" part isn't decorative: at runtime, it asserts that resume is called exactly once, catching the double-resume and never-resume bugs that were the quiet, recurring failure modes of the completion-handler era, before they cause damage further downstream.

Placement matters more than the mechanism itself. The bridge belongs at the lowest possible level, inside the actor-isolated repository implementation, never inside a ViewModel. The protocol surface exposed upward stays async throws the entire way, so no caller above the repository ever sees a continuation or a completion handler. That confines the legacy risk to a single, well-understood layer and keeps everything built on top of it clean.

One mistake is wrapping a completion-handler call in a continuation inside a TaskGroup child task without checking for cancellation first. If that child task gets cancelled before the underlying callback ever fires, the continuation is left dangling, waiting on a resume that may never come, which defeats the entire cancellation contract the rest of the architecture depends on.

Deadlocks, thread explosion, and the performance failure modes that survive correct syntax

Code can compile cleanly under Swift 6's strict checking and still perform badly, or lock up, at runtime. Correct syntax is necessary. It isn't sufficient.

Blocking the main thread remains the most common and the most damaging mistake, and it survives the transition to async/await more often than developers expect. Heavy synchronous work, decoding a large JSON payload, processing an image, running a dense computation, inside an async function does not suspend the cooperative thread pool. It blocks a thread on that pool outright, and because the pool is shared and bounded, blocking one thread has knock-on effects for everything else scheduled on it. The fix is to move CPU-bound work onto a detached task configured for that purpose inside long-running loops so the scheduler gets a chance to breathe. And @MainActor itself isn't the problem; marking a data-layer actor @MainActoris generally inadvisable, since it pins work that should run off the main executor directly onto it.

Thread explosion is the opposite failure mode but produces a similarly sluggish UI. Spinning up more concurrent tasks than there are CPU cores to run them leads to scheduling latency: the system spends its cycles context-switching between tasks rather than making progress on any of them, and the interface feels heavy for reasons that don't appear as an obvious bug. TaskGroup with a deliberate concurrency limit, controlling how many child tasks run simultaneously rather than launching everything in the group at once, is the standard mitigation for fan-out work like a bulk data sync across hundreds of records.

Actor reentrancy deadlocks are subtler still, and they catch developers who assume an actor behaves like a lock. An actor method that awaits a call that itself needs to re-enter the same actor introduces a suspension dependency that requires careful design, but if the first call was relying on some invariant staying fixed across that suspension, as though the executor had simply blocked, the second call can invalidate it before the first ever resumes. The rule that actually holds: never assume actor-isolated state is unchanged across an await point. Other callers may have mutated it while the method was suspended, since suspension is exactly the moment the actor becomes available to somebody else. Actor methods need to re-read their state after every await, not carry an assumption forward as though the await had locked the world in place. That single habit, treating every suspension point as a place where the world can change, is close to the entire discipline structured concurrency is asking iOS developers to learn.

Sources

  1. Async/Await vs Structured Concurrency in Swift: Key Differences Every iOS Developer Must Understand (2026 Guide) | Medium
  2. Swift 6 Concurrency: A Practical Guide for iOS Developers | by Gaurav Parmar | Medium
  3. avanderlee.com
  4. dzone.com
  5. nsvasilev.medium.com
  6. medium.com
  7. swiftcrafted.dev
  8. medium.com

More in Swift Architecture