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.
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:
- Cross-actor data must be
Sendable. Passing a value from one actor to another now requires the compiler to prove it's safe. @MainActorannotations propagate. A type marked@MainActorcan only be touched by the main actor; calling it from a background context errors.- Implicit captures in
Task/async letare 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:
- The outer
Task {}inherits@MainActorfrom the enclosing context. Task.detachedexplicitly leaves main for the I/O.- The result
await … .valuebrings 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:
- Day 1: flip to
targeted. Triage warnings for hot files (top 10 by edit frequency). - Days 2–4: fix hot files. Establish the four patterns above.
- Day 5: flip to
complete. Get warning count. - Days 6–10: clean up. Mostly mechanical now that patterns are settled.
- Day 10+: review
@unchecked Sendableextensions; 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:
- 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.
- The codebase teaches you the model.
@MainActorannotations are documentation. New contributors don't have to guess where things run. async letbecomes safe to use freely. Before,async letof 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.
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
References
- [1]
- [2]
- [3]
Ready to try mq-dir?
A native quad-pane file manager built for AI multi-tasking on macOS. Free, MIT licensed, zero telemetry.
Related posts
Security-scoped bookmarks on macOS: a deep dive (with the gotchas)
When the user picks a folder, you get permission. When they relaunch your app, you don't — unless you used a security-scoped bookmark. Here's everything that breaks if you didn't.
Codable migration patterns: schema evolution that doesn't lose user data
Adding a field to a Codable struct in Swift looks innocent. Done wrong, it eats your users' state. Here are five patterns that handle every kind of schema change cleanly.