Engineering

SwiftUI strict concurrency in 2026: what actually changed (and what to do about it)

Apple turned `SWIFT_STRICT_CONCURRENCY: complete` from optional to default for new projects. Here's what that breaks, what it catches, and the patterns that survive the upgrade.

Honam Kang4 min read

When SWIFT_STRICT_CONCURRENCY: complete flipped from opt-in to default, every Swift project sat through a Tuesday of hundreds of new warnings. Most weren't bugs. A few were latent nightmares.

This is what we learned migrating mq-dir's roughly 30 KLoC SwiftUI codebase, what the compiler is actually telling you, and the four patterns that turned the warnings into a clean build.

What strict concurrency really enforces

Three checks at compile time:

  1. Cross-actor data must be Sendable. Passing a value from one actor to another now requires the compiler to prove it's safe.
  2. @MainActor annotations propagate. A type marked @MainActor can only be touched by the main actor; calling it from a background context errors.
  3. Implicit captures in Task/async let are checked. The closure can't quietly capture a non-Sendable reference.

These were always semantics of the language. The compiler now refuses to let you violate them.

What broke first

The dominant warning category in our migration:

warning: capture of 'self' with non-Sendable type 'FolderBrowserViewModel'
in a `@Sendable` closure

Translation: the compiler can't prove your view model is safe to pass to a Task. In our case it was — the VM was @MainActor and only ever touched on main — but the compiler didn't know.

The four patterns that fix this cluster of warnings:

Pattern 1: Mark view models @MainActor explicitly

Before:

final class FolderBrowserViewModel: ObservableObject {
  @Published var entries: [FileEntry] = []
  
  func reload() async {
    let new = await fetchEntries()
    entries = new
  }
}

After:

@MainActor
final class FolderBrowserViewModel: ObservableObject {
  @Published var entries: [FileEntry] = []
  
  func reload() async {
    let new = await fetchEntries()  // detaches as needed
    entries = new
  }
}

The @MainActor annotation tells the compiler what was always true: this view model lives on main. SwiftUI's @StateObject and @ObservedObject now agree, and the cross-actor warnings disappear.

Pattern 2: @Sendable for value types you serialize

For types that get encoded/decoded — what we call "wire types" in mq-dir — the rule is simple: they should be value types and they should be Sendable.

struct PaneState: Codable, Sendable {
  let id: UUID
  let layout: PaneLayout
  var tabs: [TabState]
}

struct TabState: Codable, Sendable {
  let id: UUID
  var folderBookmark: Data
  var selectedURLPaths: [String]
  var viewMode: PaneViewMode
}

If you have a class here and the compiler complains, switch it to a struct. There's almost no case where a persistent state model needs reference semantics — Codable round-trips create new instances anyway, so class was always the wrong choice.

Pattern 3: Detached I/O with explicit hop back

For background work that produces state to put back on main:

@MainActor
func reload() {
  Task {
    let entries = try await Task.detached(priority: .utility) {
      try await scanDirectory(at: self.url)
    }.value
    
    self.entries = entries
  }
}

Three things matter here:

  1. The outer Task {} inherits @MainActor from the enclosing context.
  2. Task.detached explicitly leaves main for the I/O.
  3. The result await … .value brings us back to main automatically.

The compiler verifies entries is Sendable across the hop. If it isn't, you have a real bug.

Pattern 4: @unchecked Sendable for legacy frameworks

Some frameworks (NSDocument, certain AppKit types) aren't annotated yet. The compiler can't tell they're safe; you can.

extension NSWorkspace: @unchecked Sendable {}

Add a comment with the reason:

// Apple framework not yet Sendable-annotated. Documented thread-safe in
// the NSWorkspace docs (re-checked 2026-03-15). Re-evaluate when Apple
// ships annotations.
extension NSWorkspace: @unchecked Sendable {}

The comment matters. Without it, the next person looks at this in two years and assumes you took a shortcut.

The corners that hurt

Three migration footguns:

Stored Task references

You have a long-running task, you store it for cancellation:

final class FolderBrowserViewModel: ObservableObject {
  private var loadTask: Task<Void, Never>?
}

If FolderBrowserViewModel is now @MainActor, the stored task is fine — but if you assign to loadTask from inside a detached task, you're back on main implicitly. Re-read your assign-sites and confirm.

Generic constraints

func enqueue<T: Codable>(_ value: T) async {…}

The compiler now wants T: Codable & Sendable here. Adding Sendable is correct; if it breaks callers, those callers probably had an unsafe pattern.

Closure-captured locals

func search(query: String) {
  Task {
    let results = await performSearch(query: query)  // OK: query is String, Sendable
    let formatter = self.dateFormatter             // ⚠️ DateFormatter is not Sendable
    …
  }
}

DateFormatter is reference-type, not Sendable. The fix: hoist the formatting work outside the Task, or use a local formatter inside.

How we paced the migration

For mq-dir, the shape was:

  1. Day 1: flip to targeted. Triage warnings for hot files (top 10 by edit frequency).
  2. Days 2–4: fix hot files. Establish the four patterns above.
  3. Day 5: flip to complete. Get warning count.
  4. Days 6–10: clean up. Mostly mechanical now that patterns are settled.
  5. Day 10+: review @unchecked Sendable extensions; add comments.

Total cost: roughly 40 hours for ~30 KLoC. Could go faster with a fresh codebase.

What you actually gain

Three concrete wins after the migration:

  1. Real bugs surfaced. We found two cases of mutating shared state across actors that had been there for months. Both rare crashes; both impossible to repro.
  2. The codebase teaches you the model. @MainActor annotations are documentation. New contributors don't have to guess where things run.
  3. async let becomes safe to use freely. Before, async let of a complex type made you stop and audit; now the compiler audits for you.

A heuristic for "should I fix this warning?"

When you can't immediately tell:

  • Real bug indicator: the type can be touched from multiple actors AND has mutable state.
  • Noise indicator: the type is conceptually immutable (hashable, equatable, value-based) but isn't marked Sendable yet.
  • Framework gap indicator: the type is from Apple's frameworks and the warning is about implicit conformance.

For real bugs, fix. For noise, mark Sendable and move on. For framework gaps, @unchecked Sendable with a comment.

The core takeaway

Strict concurrency isn't a chore — it's the language formalizing what you already wanted to be true about your code. The migration is a one-time tax; the daily benefit is correctness without runtime races.

mq-dir's full source (currently in alpha) builds clean under SWIFT_STRICT_CONCURRENCY: complete and we're not going back.

Open source

mq-dir is fully open source.

MIT licensed, zero telemetry. Read the source, file an issue, send a PR.

★ Star on GitHub →

Frequently asked questions

Eventually yes, gradually. Set `SWIFT_STRICT_CONCURRENCY: targeted` first — it scopes warnings to types you mark Sendable. After your hot files are clean, flip to `complete`. Going `off` to `complete` directly buries you in warnings.

References

  1. [1]
  2. [2]
  3. [3]

Ready to try mq-dir?

A native quad-pane file manager built for AI multi-tasking on macOS. Free, MIT licensed, zero telemetry.

v0.1.0-beta.12 · MIT · macOS 14.0+ · github