Back to Blog

Concurrency boundaries in networking and persistence

Most concurrency bugs in iOS apps are not race conditions in the textbook sense. They are boundary violations: the wrong actor touching the wrong layer at the wrong time.

10 min read

Most concurrency bugs in iOS apps are not race conditions in the textbook sense.

They are boundary violations.

The network callback mutates a @MainActor model from a background queue. The database layer exposes a mutable cache that six views read simultaneously. A Task fires a request, the view dismisses, and the response writes into state that now belongs to a different screen.

Then somebody blames Swift concurrency for being complicated.

Swift concurrency is not the problem.

The problem is that the app does not have clear rules about which layer runs on which executor, who owns mutable state, and how data crosses those lines.

1. Boundaries exist whether you draw them or not

Every app has layers:

  1. UI / view tree
  2. feature models or view models
  3. network client
  4. persistence layer
  5. platform APIs

If you do not decide where concurrency boundaries live, the compiler will decide for you at the worst possible moment.

And by “compiler,” I mean the runtime crash you get when an @MainActor property is touched off-main by a URLSession completion handler that forgot to hop back.

Explicit boundaries are not bureaucracy. They are the difference between a codebase you can reason about and one where every await feels like a gamble.

2. The UI layer should not think about threads

Views should be main actor. That is not negotiable.

What varies is how much work the view layer is allowed to trigger directly.

A healthy rule:

  • views observe and render
  • feature models decide what to load and when
  • the network client handles transport
  • the persistence layer handles storage

If a view is constructing URLRequest objects, parsing JSON, or deciding retry policy, the boundary has already eroded.

Bad boundaries are not always dramatic. Sometimes they look like a .task modifier that does three network calls, two parses, and a Core Data write before updating a single @State boolean.

That is not a task. That is a screenplay.

3. The network layer should be an actor

Your network client should probably be an actor.

Not because actors are trendy. Because a network client has mutable state that must be serialized:

  • in-flight request registry
  • authentication token storage
  • rate-limit or retry bookkeeping
  • response cache

If you build this as a class with manual DispatchQueue barriers, you are writing a worse actor by hand.

actor NetworkClient {
    private let session: URLSession
    private var activeTasks: [RequestID: Task<Data, Error>] = [:]
    private var tokenProvider: TokenProvider

    func data(for request: APIRequest) async throws -> Data {
        let token = try await tokenProvider.validToken()
        let urlRequest = try request.urlRequest(adding: token)

        let task = Task {
            defer { Task { await self.removeTask(id: request.id) } }
            let (data, response) = try await session.data(for: urlRequest)
            try response.validate()
            return data
        }

        activeTasks[request.id] = task
        return try await task.value
    }

    func cancel(requestID: RequestID) {
        activeTasks[requestID]?.cancel()
    }

    private func removeTask(id: RequestID) {
        activeTasks[id] = nil
    }
}

This keeps the mutable surface small and the serialization automatic. The caller awaits results. The actor guarantees no two tasks corrupt the registry.

4. Persistence should be an actor too, but a different one

Database layers have the same shape as network layers:

  • mutable connection pool or context
  • pending writes that must serialize
  • in-memory caches that can race

Making the persistence layer an actor is usually correct. Making it the same actor as the network layer is usually wrong.

If network and persistence share one actor, a slow network call blocks a local database read. That is not serialization. That is a traffic jam.

Separate actors for separate concerns:

actor PersistenceStore {
    private let database: Database
    private var cache: [EntityID: Entity] = [:]

    func save(_ entities: [Entity]) async throws {
        try await database.write { context in
            for entity in entities {
                context.upsert(entity)
            }
        }
        for entity in entities {
            cache[entity.id] = entity
        }
    }

    func entity(id: EntityID) async -> Entity? {
        if let cached = cache[id] {
            return cached
        }
        return try? await database.read { context in
            context.entity(id: id)
        }
    }
}

Now network delays do not stall local queries. Cache updates happen only after the write commits. Reads can be fast-path cached without touching the database at all.

5. Crossing boundaries should be obvious

The most dangerous code is the glue between layers.

Not because glue is inherently bad, but because it is where assumptions about isolation quietly break.

A few rules for crossing boundaries:

Do not let async work leak past its owner

If a feature model starts a task, the feature model should own its lifecycle. When the model deallocates or the feature deactivates, pending work should be canceled.

Do not pass mutable references across actor boundaries

Sending a mutable class instance from a background actor to @MainActor is asking for trouble. Send values. Structs. Copied snapshots. Not shared mutable objects.

Do not hide actor hops inside innocent-looking properties

A computed property that does an await under the hood is a surprise. Surprises in concurrent code become bugs.

If crossing a boundary costs an await, make that visible in the method name or the API shape.

6. The feature model is where boundaries meet

The feature model sits between UI and infrastructure. That makes it the natural place to coordinate:

  1. the view asks for data
  2. the model checks persistence first
  3. if stale or missing, the model requests from network
  4. the model persists the result
  5. the model publishes the update to the view

This is not new. It is just easier to get wrong when every layer is async and actor-isolated.

A practical pattern:

@MainActor
@Observable
final class InboxModel {
    private let network: NetworkClient
    private let store: PersistenceStore
    var messages: [Message] = []
    var loadState: Loadable<Void> = .idle

    func refresh() async {
        loadState = .loading
        do {
            let remote = try await network.data(for: .inbox)
            let messages = try MessageDecoder.decode(remote)
            try await store.save(messages)
            self.messages = try await store.allMessages()
            loadState = .loaded
        } catch {
            loadState = .failed(message: error.localizedDescription)
        }
    }
}

Note what happens here:

  • the model is @MainActor because the UI observes it
  • network and store are separate actors, so the model awaits them
  • the model still owns the sequencing: network, then store, then state update
  • no layer reaches through another layer directly

This is more explicit than a framework that hides the boundaries. That explicitness is the feature.

7. Beware the implicit main actor assumption

Before Swift 6, a lot of networking code looked like this:

session.dataTask(with: request) { data, response, error in
    self.messages = parse(data) // oops
}

It worked until it did not. Now, in a strict concurrency world, the compiler catches it.

But there is a quieter version of the same bug:

Task {
    let data = try await network.data(for: request)
    messages = try decoder.decode(data)
}

If this Task runs from a @MainActor context, it inherits the main actor. That means the decode happens on the main thread. For small payloads, fine. For large payloads, you just froze the UI.

The fix is not to avoid Task. The fix is to be explicit about where work runs:

Task {
    let data = try await network.data(for: request)
    let messages = try await decoder.decode(data) // if heavy, push off main actor
    await MainActor.run {
        self.messages = messages
    }
}

Or, better, keep the heavy work inside the infrastructure actor where it belongs.

8. Cancellation is a boundary concern too

Cancellation in Swift concurrency is cooperative. That means it only works if every layer participates.

If the view cancels a task but the network client keeps a reference in an actor-private dictionary, the cancellation is theater.

A clean boundary:

  • the view creates a Task scoped to its lifecycle
  • the task calls the feature model
  • the feature model propagates cancellation to the network client
  • the network client cancels the underlying URLSessionTask

If any layer swallows the cancellation or ignores Task.isCancelled, the boundary leaks.

9. Do not share mutable caches across actors

A common pattern that looks efficient and is actually dangerous:

class SharedCache {
    var storage: [String: Data] = [:]
}

// accessed from multiple actors

Even if reads and writes are wrapped in sync blocks, sending this object across actors violates isolation.

Better:

  • one actor owns the cache
  • callers request values with await
  • the cache actor manages eviction and thread safety internally

If you truly need lock-free read access from the main actor, use an immutable snapshot updated by the owning actor. Do not share the mutable source.

10. Testing boundaries is where you find the real bugs

Unit tests for individual actors are easy. They run in isolation, they pass, and they give false confidence.

The bugs live in the handoffs:

  • what happens when the network is slow and the user dismisses the view?
  • what happens when a database write fails after the network succeeds?
  • what happens when two feature models request the same resource concurrently?
  • what happens when cancellation fires mid-write?

Test those.

A useful pattern is to build the network client and persistence store with protocol-based seams, then inject fake implementations that let you control timing:

let network = FakeNetwork(delay: .seconds(5))
let store = FakeStore(writeDelay: .seconds(1))
let model = InboxModel(network: network, store: store)

let task = Task { await model.refresh() }
try await Task.sleep(for: .seconds(2))
task.cancel()

// assert no crash, no partial state, no orphaned write

If you only test the happy path, you do not have tests. You have demonstrations.

11. The practical boundary rules I would enforce

For a normal product app, these rules are non-negotiable:

  1. UI is @MainActor and does not do parsing, decoding, or database writes
  2. Network client is its own actor with isolated mutable state
  3. Persistence layer is its own actor, separate from network
  4. Feature models own sequencing and lifecycle, not the view
  5. Mutable state never crosses actor boundaries as a shared reference
  6. Cancellation propagates through every layer explicitly
  7. Heavy work stays in infrastructure actors; only results reach main actor
  8. Tests verify handoffs, failures, and cancellation, not just success

None of this requires a framework. It requires discipline.

12. The real goal is local reasoning

Concurrency is hard because humans are bad at holding multiple timelines in their heads.

The point of boundaries is to make that unnecessary.

When each layer has a clear owner, a clear executor, and a clear contract for crossing over, you can reason about one layer at a time.

That is the difference between a codebase that feels fast and loose and one that feels boring in the best way.

Boring concurrency is the goal.

If your async code feels exciting, your boundaries are probably too permeable.

Draw them tighter.