Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 013 / 100

Swift 6 strict concurrency: patterns, pitfalls and fixes

Catch data races at compile time.

Expona Admin7 min read

The Race Condition You Can't See

Data races have haunted Swift development since concurrency arrived in iOS 13. You ship an app, users report crashes on background syncs, and your logs give you nothing. Swift 6 strict concurrency changes that equation entirely—the compiler now catches data races at compile time, before your code ships. This article walks through Swift 6 strict concurrency: patterns, pitfalls and fixes, showing you how to enable the checks, fix warnings gradually, architect your types for thread safety, and avoid the common pitfalls our teams have hit in production.

Enable Complete Concurrency Checking

The first step is switching on the strictest compiler mode. In Xcode, select your target, go to Build Settings, search for "concurrency", and set Complete Concurrency Checking to Strict. This tells Swift to validate that all access to shared mutable state is protected by isolation mechanisms.

When you flip this switch on an existing codebase, you'll likely see hundreds of warnings. That's normal. The warnings are telling you where data races could happen—places where multiple threads (or tasks) could read or write the same value simultaneously without synchronization.

Here's what a typical warning looks like:

swift
class UserRepository {
  var cachedUsers: [User] = []  // ⚠️ Main actor-isolated property can not be mutated from a non-isolated context
  
  func fetchUser(id: Int) async {
    // called from a background task
    cachedUsers.append(user)  // ⚠️ Mutating access requires main thread
  }
}

The compiler is right. If fetchUser runs on a background thread and tries to mutate cachedUsers while the UI thread is reading it, you have a race. Swift 6 catches this statically instead of letting it blow up at runtime.

Start by enabling strict checking on one target at a time, not your whole project. Fix that target, commit, then move on. Rushing through all the warnings in one session leads to bugs.

Fix Sendable Warnings Gradually

At the heart of Swift's concurrency model is the concept of Sendable. A type is Sendable if it's safe to pass between isolation domains—like from a background task to the main thread, or between actors. Value types (structs, enums) are implicitly Sendable. Reference types (classes) are not, unless you explicitly mark them and prove it to the compiler.

When the compiler sees you passing a non-Sendable class across a boundary, it warns you. Here's a real scenario from a ride-hailing backend we worked on:

swift
class LocationUpdate {
  var latitude: Double
  var longitude: Double
  var timestamp: Date
  
  init(lat: Double, lng: Double) {
    self.latitude = lat
    self.longitude = lng
    self.timestamp = Date()
  }
}

Task {
  let update = LocationUpdate(lat: 37.7749, lng: -122.4194)
  try await locationService.report(update)  // ⚠️ Passing non-Sendable across isolation boundary
}

The fix depends on your type. If it's truly immutable, mark it Sendable and make all properties let:

swift
final class LocationUpdate: Sendable {
  let latitude: Double
  let longitude: Double
  let timestamp: Date
  
  init(lat: Double, lng: Double) {
    self.latitude = lat
    self.longitude = lng
    self.timestamp = Date()
  }
}

If your type needs to be mutable and shared, don't mark it Sendable. Instead, wrap it in an actor (covered below). If it's a value type already, you get Sendable for free:

swift
struct LocationUpdate: Sendable {
  let latitude: Double
  let longitude: Double
  let timestamp: Date
}

Don't rush to mark everything Sendable. Each one is a promise: "this type is safe to share across threads." If you break that promise, you've just introduced a data race. Be conservative. If you're unsure, don't mark it Sendable yet.

Mark UI Types @MainActor

UIKit and SwiftUI components must run on the main thread. Swift lets you declare that explicitly with the @MainActor annotation. This tells the compiler: "all code in this type runs on the main thread, so you don't need to synchronize access."

Apply @MainActor to view controllers, view models, and any type that owns UI state:

swift
@MainActor
final class OrderViewController: UIViewController {
  var order: Order?
  var button: UIButton!
  
  override func viewDidLoad() {
    super.viewDidLoad()
    // This runs on main, guaranteed
  }
  
  func updateUI(with order: Order) {
    self.order = order
    button.setTitle(order.id, for: .normal)
  }
}

Now, if you try to call updateUI from a background task, the compiler stops you:

swift
Task.detached {
  let order = try await fetchOrderFromAPI()
  viewController.updateUI(with: order)  // ⚠️ Call to main actor-isolated instance from non-isolated context
}

The fix is explicit: hop back to the main thread with @MainActor.run or DispatchQueue.main:

swift
Task.detached {
  let order = try await fetchOrderFromAPI()
  await MainActor.run {
    viewController.updateUI(with: order)
  }
}

Or use a structured task that respects the calling context:

swift
Task {
  let order = try await fetchOrderFromAPI()
  viewController.updateUI(with: order)  // ✓ No warning; this task runs on main
}

For SwiftUI, mark your @Observable view models @MainActor as well:

swift
@MainActor
@Observable
final class OrderVM {
  var order: Order?
  var loading = false
  
  func load() async {
    loading = true
    defer { loading = false }
    order = try? await fetchOrder()
  }
}

Use Actors for Shared State

Actors are the workhorse of Swift concurrency. An actor serializes access to its mutable state—only one task can run an actor method at a time. Use them for any shared, mutable resource: caches, databases, sockets, device state.

Here's an actor from a trading backend we worked on, managing a live quote cache:

swift
actor QuoteCache {
  private var quotes: [String: Quote] = [:]
  
  func getQuote(symbol: String) -> Quote? {
    quotes[symbol]
  }
  
  func setQuote(_ quote: Quote, for symbol: String) {
    quotes[symbol] = quote
  }
  
  func update(from feed: QuoteFeed) async {
    for quote in feed.quotes {
      quotes[quote.symbol] = quote
    }
  }
}

let cache = QuoteCache()

// Multiple tasks can safely call this concurrently
Task { await cache.setQuote(quote1, for: "AAPL") }
Task { await cache.setQuote(quote2, for: "MSFT") }
Task { let q = await cache.getQuote(symbol: "AAPL") }

Each await point is a checkpoint: the task yields to let other tasks use the actor. If you call a property on an actor, you need to await:

swift
// Compiler error
let count = cache.quotes.count

// Correct
let quote = await cache.getQuote(symbol: "AAPL")

Actor properties are isolated. You can't read them directly outside the actor without going through a method. This forces you to think about concurrency explicitly. That's the point.

If you have a shared resource that's truly immutable, you don't need an actor. Just ensure it's Sendable:

swift
let appConfig = AppConfig()  // immutable, Sendable

Task.detached {
  print(appConfig.apiURL)  // ✓ Safe to read from any thread
}

Common Mistakes

  • Sprinkling @MainActor on everything. Only mark types that genuinely must run on the main thread. Overusing it defeats the purpose of concurrency.
  • Marking a mutable class Sendable without synchronization. This is a lie to the compiler. The class will still race. Use an actor instead.
  • Ignoring warnings and disabling strict checking. The warnings exist because the compiler found a real race condition. Fix the root cause, not the symptom.
  • Assuming all value types are safe. They are thread-safe, but if they contain references to non-Sendable classes, you still have problems.
  • Not testing on devices. Strict concurrency catches many races at compile time, but not all. Still test on real hardware under load.

Key Takeaways

  • Enable Complete Concurrency Checking: Strict in Build Settings to catch data races at compile time.
  • Fix Sendable warnings gradually by making types immutable and adding the Sendable conformance, or by wrapping mutable types in actors.
  • Mark UI types with @MainActor to guarantee main-thread execution and eliminate synchronization overhead.
  • Use actors for shared, mutable state—they serialize access and make concurrency safe and explicit.
  • Treat compiler warnings as bugs, not inconveniences. Swift 6's strictness catches real race conditions before users see them.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles