Swift 6 Strict Concurrency - Eliminate Data Races and Boost iOS Performance

Swift 6’s strict concurrency isn’t a “nice to have.” It’s the cleanest, most scalable way to kill data races in your app — at compile time. If you flip it on and actually fix the warnings (don’t silence them), you get a codebase that’s easier to reason about, safer across teams, and faster under load.

Below is a practical, opinionated guide to turn it on, fix the fallout, and come out with a better architecture. Code snippets included.


Turn it on, incrementally, and surface the truth

My take: start with warnings, then graduate to errors. You want signal without breaking your CI on day one.

Xcode projects

SwiftPM (while still on Swift 5 language mode)

// Package.swift (Swift 6 tools or later)
.target(
  name: "AppCore",
  swiftSettings: [
    .enableUpcomingFeature("StrictConcurrency")
  ]
)

This enables complete actor-isolation and Sendable checking as warnings so you can see the blast radius.

Swift 6 language mode

Only move a module to Swift 6 mode after you’ve burned down warnings in that module.


Fix Sendable the right way

Principle: data that crosses concurrency domains must be provably safe to share.

Prefer value types (structs, enums)

If a type is immutable or only holds Sendable fields, the compiler will often infer Sendable. If not, declare it explicitly:

public struct UserSnapshot: Sendable {
  public let id: UUID
  public let name: String
  public let avatarURL: URL?
}

Avoid sprinkling @unchecked Sendable

Use it as a last resort and leave a TODO with a plan to remove. Wrap legacy reference types behind an actor or make them immutable.

final class LegacyThing { /* not thread-safe */ }

// Temporary: contain it
actor LegacyThingBox {
  private let inner = LegacyThing()
  // expose async methods that serialize access to `inner`
}

Fix “capture of non-Sendable in @Sendable closure”

Don’t pass around mutable classes inside parallel tasks. Either:

  1. Snapshot the data into a Sendable value, or
  2. Give the object an actor “front door,” and call it with await.

This is where you pay down the technical debt you’ve been ignoring. It’s worth it.


Actor isolation: be intentional with @MainActor

Hot take: too many projects mark entire view models as @MainActor and then complain the UI is slow. Mark only what must run on the main thread.

UI types and UI entry points

@MainActor
final class ProfileViewModel: ObservableObject {
  @Published private(set) var name: String = ""

  // UI-initiated action (touches UI state) — keep it on main actor
  func load() async {
    // <- cross-actor hop
    let user = await userService.currentUser()
    name = user.name
  }
}

Use nonisolated to avoid unnecessary hops

If something is pure (no access to isolated state), make it nonisolated so it can run anywhere without the main-actor hop:

@MainActor
final class ProfileViewModel: ObservableObject {
  private let formatter = PersonNameComponentsFormatter()
  @Published private(set) var name: String = ""

  // Pure helper: no `self` or isolated state
  nonisolated func format(_ user: UserSnapshot) -> String {
    user.name.uppercased()
  }

  func update(_ user: UserSnapshot) {
    name = format(user) // no hop, no await
  }
}

Rules of thumb


Adopt global actors for shared subsystems

When many types must serialize access to the same resource (disk cache, analytics pipeline, database), global actors shine. They give you a single, app-wide executor for that subsystem — no copy-paste DispatchQueues, no hand-rolled locks.

Define a global actor

@globalActor
actor StorageActor {
  static let shared = StorageActor()
}

This makes @StorageActor a usable attribute across your codebase.

Isolate a subsystem to it

@StorageActor
final class DiskCache {
  private var index: [String: URL] = [:]

  func store(_ data: Data, for key: String) throws {
    let url = /* compute file URL */
    try data.write(to: url)
    index[key] = url
  }

  func load(_ key: String) -> Data? {
    guard let url = index[key] else { return nil }
    return try? Data(contentsOf: url)
  }
}

Now any call into DiskCache synchronizes through StorageActor.shared. You can mark other types (ImageDatabase, FileMover) with @StorageActor and they’ll all share the same serialized executor. This is a clean, declarative way to guarantee “only one thing touches the disk at a time.”

When to use a global actor vs plain actor?


Protocols, cross-module code, and migration tactics

Strict concurrency will uncover friction where protocols or 3rd-party APIs weren’t annotated.

Tips


Concrete before/after examples

Example A: Non-Sendable capture → value snapshot

Before

final class Session { var token: String } // not Sendable
let session = Session()

Task.detached { // runs concurrently
// warning: capture non-Sendable
  await api.fetchProfile(token: session.token)
}

After

// snapshot the value
let token = session.token

Task.detached {
  await api.fetchProfile(token: token) // ok: String is Sendable
}

Example B: UI work vs background work

@MainActor
final class PhotosViewModel: ObservableObject {
  @Published private(set) var thumbnails: [UIImage] = []

  func refresh() async {
    // hop off main actor to do CPU work
    // not main-isolated
    let images = await ImagePipeline.shared.loadThumbnails()
    thumbnails = images // back on main
  }
}

@globalActor
actor ImagePipeline {
  static let shared = ImagePipeline()

  func loadThumbnails() async -> [UIImage] {
    // decode/resize here; serialized by the pipeline actor
  }
}

Example C: Nonisolated fast path inside a main-actor type

@MainActor
final class FormatterVM {
  nonisolated func formatCount(_ n: Int) -> String {
    NumberFormatter.localizedString(
      from: n as NSNumber, number: .decimal)
  }

  func updateLabel(_ n: Int) {
    label = formatCount(n) // no actor hop, no await
  }

  @Published private(set) var label = ""
}

Ensure the helper is actually pure—no shared global DateFormatters unless they’re isolated.


Team workflow that works

  1. Flip on “Strict Concurrency: Complete” across modules to surface all warnings. Track counts in CI.
  2. Triage by category: Sendable first (easy wins with value types), then actor-isolation fixes (@MainActor, nonisolated, global actors).
  3. Cut tickets per dependency: libraries missing annotations? Fork or open PRs.
  4. Move modules to Swift 6 language mode once warning-free. Repeat until everything is in Swift 6.

Pitfalls to avoid


Why this makes your app faster

Strict concurrency isn’t just safety theater. By isolating subsystems and removing accidental main-actor hops, you:


Final nudge

Turn it on. Fix the warnings. Introduce global actors where you share mutable state. Use @MainActor sparingly and nonisolated aggressively for pure, fast paths. You’ll end up with code that reads like it intends to run correctly — not code that hopefully does.