In short
- Migrate to Swift 6 strict concurrency per target: complete checking as warnings first, fix from the leaf modules up, and flip the language mode last on the app target.
- A
static varsingleton is the first error in most apps; the fix is aletof a Sendable type,@MainActor, or an actor, andnonisolated(unsafe)only for a value written once before any concurrency starts. @unchecked Sendable,nonisolated(unsafe)andMainActor.assumeIsolatedall move a check from the compiler to you; each one needs a comment that says what invariant you are now enforcing.- Swift 6 language mode adds runtime checks: a main actor closure that a pre-concurrency API calls from a background queue traps instead of racing.
- In interviews the question is not what Sendable means but what you now check by hand after the migration, and what happens at runtime when you are wrong.
The most common first attempt at a Swift 6 strict concurrency migration I see is to turn on the language mode for the whole app, look at the error count, and turn it back off. That is the wrong order. The migration is not one switch. It is a sequence of small, reviewable changes that runs from the modules with no dependencies up to the app target, and the errors fall into a short list of categories with known fixes.
This is the playbook I use when I review a UIKit or SwiftUI codebase moving to Swift 6 language mode. Every snippet compiles with the Swift 6.3 compiler in Swift 6 mode, and every diagnostic I quote is what that compiler printed. The snippets avoid UIKit on purpose so you can paste them into a file and check them yourself.
The order of work for a Swift 6 strict concurrency migration
Two settings matter, and they are separate. Strict concurrency checking decides how much the compiler analyzes: minimal, targeted or complete. Language mode decides whether data-race findings are warnings or errors. Swift 5 mode with complete checking gives you every diagnostic Swift 6 would give, as warnings. That is where the work happens.
- Set complete checking on every target while staying in Swift 5 mode. In Xcode that is
SWIFT_STRICT_CONCURRENCY = complete; in a package it is theStrictConcurrencyupcoming feature. Nothing breaks the build yet. - Draw the module graph and start at the leaves: models, networking, persistence, utilities. A leaf has no internal dependencies, so its fixes do not depend on anyone else's.
- Fix one leaf until it has zero concurrency warnings, flip that target to Swift 6 mode, merge. Swift 6 mode is a per-module setting, so a Swift 6 module can be imported by a Swift 5 one and the other way around.
- Move up one level. Upstream modules now see accurate
Sendableand isolation information from the leaves, which removes a class of false positives. - Do the app target last. It holds the delegates, the view controllers and the singletons, so it has the most errors and gets the most help from finished dependencies.
In a Swift package the mixed state looks like this. The tools version 6.0 manifest defaults every target to Swift 6 mode, so the targets you have not finished opt back into Swift 5 with complete checking.
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "Features",
platforms: [.iOS(.v16)],
targets: [
// Leaf, already clean: Swift 6 language mode by default.
.target(name: "Networking"),
// Not yet: Swift 5 mode, complete checking as warnings.
.target(
name: "Checkout",
dependencies: ["Networking"],
swiftSettings: [
.swiftLanguageMode(.v5),
.enableUpcomingFeature("StrictConcurrency"),
]
),
]
)Swift 6.2 adds default main actor isolation (SWIFT_DEFAULT_ACTOR_ISOLATION in Xcode, .defaultIsolation(MainActor.self) in a 6.2 manifest). For a UI-heavy app target it removes many annotations, but it changes the meaning of every unannotated declaration in the module. Ship it as its own change, not inside the migration.
Global and static mutable state: the first wall
The first error I see in most codebases is a singleton. Any global or static var is shared mutable state that any thread can touch, and Swift 6 rejects it outright.
final class Settings {
static var shared = Settings()
var theme = "light"
}The compiler says static property shared is not concurrency-safe because it is nonisolated global shared mutable state, and it suggests a few ways out. They are not equivalent. Pick by asking what the state really is.
struct AppConfig: Sendable {
let apiBaseURL: String
let retryLimit: Int
static let current = AppConfig(apiBaseURL: "https://api.example.com",
retryLimit: 3)
}
@MainActor
final class SessionStore {
static let shared = SessionStore()
var userID: String?
}
actor TokenStore {
static let shared = TokenStore()
private var token: String?
func update(_ newValue: String?) { token = newValue }
func current() -> String? { token }
}
enum LaunchFlags {
// Written once in main() before any task or queue starts.
// Read-only afterwards. Nothing checks this but us.
nonisolated(unsafe) static var isUITesting = false
}- Configuration that never changes after launch becomes a
static letof aSendabletype. Astatic letis initialized lazily and exactly once, so the only requirement is that the value itself is safe to share. - State the UI reads and writes becomes
@MainActor. Most app singletons belong here, and it costs nothing at call sites that are already on the main actor. - State touched from many places off the main thread becomes an actor. Every access becomes
await, which is the price and also the point: callers now see that the access can suspend. - A value written once before any concurrency starts can be
nonisolated(unsafe). It turns checking off for that one declaration and nothing else. The compiler trusts you completely, so the comment above it is the only enforcement there is.
Asked in the loopWhy not mark the singleton nonisolated(unsafe) and move on? The error goes away.
It's fine because we only write it from the main thread anyway. The compiler is being too strict.
The error goes away and the race stays. nonisolated(unsafe) means I, not the compiler, guarantee that no two threads touch it at once. That holds for a flag written once in main(). It does not hold for a session object that a network callback updates. For that I want @MainActor or an actor, so the next person who writes to it from a background queue gets a compile error instead of a crash report.
The strong answer names the invariant and who enforces it. That is the whole grading rubric for this question.
Non-Sendable types crossing isolation
The second category is values moving between isolation domains: from the main actor into an actor, or into a Task. Start with value types. A struct or enum whose stored properties are all Sendable is Sendable implicitly when it is internal. A public type is not inferred, so in a leaf module you add the conformance explicitly, and that is most of the leaf work.
Classes are where it gets interesting. Swift 6 does not require every class you pass across a boundary to be Sendable. Region-based isolation lets you pass a non-Sendable value if the compiler can prove nothing else still holds a reference to it. The sending parameter modifier states that contract in a function signature.
final class ReportBuilder {
var lines: [String] = []
}
actor Uploader {
func upload(_ report: ReportBuilder) async {
print(report.lines.count)
}
}
@MainActor
func submit(to uploader: Uploader) async {
let report = ReportBuilder()
report.lines.append("header")
await uploader.upload(report) // fine: report is disconnected
}
func enqueue(_ report: sending ReportBuilder) {
Task {
report.lines.append("footer")
}
}Add one line after the upload call that touches report again and the compiler reports that sending report risks causing data races, because the caller kept a reference it could use while the actor runs. Drop sending from enqueue and the Task fails with passing closure as a sending parameter risks causing data races. The fix in both cases is ownership: create the value, hand it off, stop using it.
When a class really is shared, for example a cache or a counter used from several threads, the documented exception is @unchecked Sendable with a lock. It compiles because you told the compiler to stop checking, so write down what makes it true.
import Foundation
// Exception: guarded by `lock`. Every stored var is private and
// only touched inside withLock. Replace with Mutex at iOS 18.
final class RequestCounter: @unchecked Sendable {
private let lock = NSLock()
private var counts: [String: Int] = [:]
func record(_ path: String) -> Int {
lock.withLock {
counts[path, default: 0] += 1
return counts[path, default: 0]
}
}
}If your deployment target is iOS 18, Mutex from the Synchronization module gives you the same thing as a checked Sendable type: the protected state lives inside the mutex and cannot be reached without the lock. I prefer it when the target allows, because the conformance is no longer a promise.
Delegates and callbacks from pre-concurrency frameworks
A @MainActor view model that conforms to a delegate protocol from an older framework is the third wall. The protocol requirement is nonisolated, the method you wrote is main actor isolated, and the compiler reports that the conformance crosses into main actor-isolated code and can cause data races. I reproduced it with a small Swift 5 module standing in for a vendor SDK.
@preconcurrency import of that module suppresses Sendable diagnostics for its types, which is what you want for code you cannot change. It does not fix the isolation mismatch. For that you have three options, and they differ in when a wrong assumption fails.
import Foundation
@preconcurrency import BarcodeKit
@MainActor
final class ScanViewModel: NSObject, BarcodeReaderDelegate {
private(set) var lastCode: String?
private let barcodeReader = BarcodeReader()
override init() {
super.init()
barcodeReader.delegate = self
}
// The SDK documents main-thread delivery; the types don't say so.
nonisolated func reader(_ reader: BarcodeReader, didRead code: String) {
MainActor.assumeIsolated {
lastCode = code
}
}
}MainActor.assumeIsolated checks at runtime that you are on the main actor and traps if you are not. Use it when the framework documents main thread delivery. The alternative is extension ScanViewModel: @preconcurrency BarcodeReaderDelegate, which keeps the method isolated and inserts the same runtime check for you. The compiler even suggests it as a note on the error.
Swift 6.2 adds a third option that I now reach for first: an isolated conformance. The conformance itself is main actor isolated, so the compiler knows the protocol can only be used from the main actor.
import Foundation
@preconcurrency import BarcodeKit
@MainActor
final class ScanViewModel: NSObject {
private(set) var lastCode: String?
}
extension ScanViewModel: @MainActor BarcodeReaderDelegate {
func reader(_ reader: BarcodeReader, didRead code: String) {
lastCode = code
}
}Wrapping completion handlers
For callback APIs, wrap once at the module boundary and expose async upward. The continuation has to be resumed exactly once on every path; the checked variant tells you at runtime when that rule is broken.
@preconcurrency import BarcodeKit
extension BarcodeReader {
func start() async throws -> Int {
try await withCheckedThrowingContinuation { continuation in
start { result in
continuation.resume(with: result)
}
}
}
}The trade-offs of continuations are covered in the Swift concurrency interview questions article, so I will not repeat them here.
NotificationCenter, KVO, Timer and Combine on the main actor
Block-based observers are the category most likely to slip through. The closure you pass to addObserver(forName:object:queue:using:), to a KVO observe call or to Timer.scheduledTimer is @Sendable, so touching main actor state from inside it is an isolation error. On the 6.3 compiler all three are reported as warnings, not errors, even in Swift 6 mode. A team that only fixes errors ships them.
The fix I prefer replaces the callback with something the concurrency model understands. For notifications that is the async sequence, consumed in a task the view model owns.
import Foundation
@MainActor
final class FeedViewModel {
private(set) var needsRefresh = false
private var observation: Task<Void, Never>?
func start() {
observation = Task { [weak self] in
let changes = NotificationCenter.default
.notifications(named: .NSSystemClockDidChange)
for await _ in changes {
self?.needsRefresh = true
}
}
}
func stop() {
observation?.cancel()
}
}If you keep the block API with queue: .main, wrap the body in MainActor.assumeIsolated. It is correct because .main guarantees the thread, and it will trap if someone changes the queue later. For timers, a task loop replaces Timer and removes the retain cycle and the invalidation bookkeeping at the same time.
import Foundation
@MainActor
final class CountdownModel {
private(set) var remaining = 30
private var ticker: Task<Void, Never>?
func start() {
ticker = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(1))
guard let self, self.remaining > 0 else { return }
self.remaining -= 1
}
}
}
func stop() {
ticker?.cancel()
ticker = nil
}
}Combine is the surprising one. A sink closure is not @Sendable, so when you create it in a @MainActor type it is inferred to be main actor isolated, and the compiler accepts it without a word. It does not check which scheduler you receive on.
import Combine
import Foundation
@MainActor
final class UploadModel {
private(set) var progress = 0.0
private var cancellables = Set<AnyCancellable>()
func bind(_ updates: some Publisher<Double, Never>) {
updates
.receive(on: DispatchQueue.global()) // the bug
.sink { [weak self] value in
self?.progress = value
}
.store(in: &cancellables)
}
}I compiled this with a small driver in both modes and sent one value. In Swift 5 mode the property was set off the main thread and the process carried on. In Swift 6 mode the process stopped with an illegal instruction trap before the closure body ran, because Swift 6 mode checks at runtime that a main actor closure really runs on the main actor. With receive(on: DispatchQueue.main) both modes run cleanly. After the flip this shows up as crash reports from paths your tests never ran, so audit every receive(on: before you flip the app target.
deinit and isolation
A deinit in a @MainActor class is nonisolated, because the last reference can be released on any thread. You can still touch stored properties whose types are Sendable, so cancelling a Task works. You cannot touch a non-Sendable stored property or call an isolated method.
import Foundation
@MainActor
final class PlayerController {
private var timer: Timer?
private var names: [String] = []
deinit {
timer?.invalidate()
print(names)
}
}The compiler reports that it cannot access property timer with a non-Sendable type from nonisolated deinit. This is one more reason to move from Timer to a Task. If the cleanup must call main actor code, Swift 6.2 accepts isolated deinit, which runs the deinitializer on the class's actor. It may have to hop there first, so cleanup is no longer synchronous with the last release. Do not use it for anything that must be freed at that exact moment.
Test targets: XCTest and Swift Testing
Test targets fail in a predictable way. An XCTestCase method is nonisolated, so a test that creates a @MainActor model gets two errors: the initializer cannot be called from outside the actor, and the property cannot be referenced from the nonisolated autoclosure inside XCTAssertEqual. Annotate the test method, not the whole case class, and make it async when the code under test is.
import XCTest
import Cart
final class CartModelTests: XCTestCase {
@MainActor
func testAddAppendsItem() async {
let model = CartModel()
await model.add("book")
XCTAssertEqual(model.items, ["book"])
}
}In Swift Testing you can isolate the whole suite. Tests run in parallel by default, so a suite that shares main actor state across tests should be @MainActor or .serialized, not left to luck.
import Testing
import Cart
@MainActor
struct CartModelTests {
let model = CartModel()
@Test func addAppendsItem() async {
await model.add("book")
#expect(model.items == ["book"])
}
}I migrate each test target right after the module it tests. It is the fastest check on whether an isolation choice is usable from outside the module.
Why senior loops ask about this migration
In the senior and staff iOS loops I run and review, the Swift 6 strict concurrency question has moved from definitions to migration. Most candidates can define Sendable. Fewer can say what they would do with a codebase full of singletons, a vendor SDK with delegate callbacks and a Combine layer, and in what order. That answer shows whether someone has done the work or read about it.
Asked in the loopYou flipped the app target to Swift 6 and the build is green. What did you trade for that?
Nothing. If it compiles in Swift 6 mode, there are no data races.
Every @unchecked Sendable, nonisolated(unsafe) and assumeIsolated is a place where I replaced a compile-time proof with a runtime check or with my own promise. I keep a list of them, each with a comment naming the invariant. Swift 6 mode also adds runtime isolation checks, so a closure a legacy API calls on the wrong queue now traps instead of racing. I would watch crash reports closely for the first release after the flip.
The follow-up I use is short: pick one of those escape hatches in your codebase and tell me what happens at runtime when the invariant is wrong. Traps loudly, races silently, or deadlocks are all different answers, and a senior candidate knows which one applies.
Practicing the answers
The migration steps are learnable from a codebase. The interview version is harder, because you have to explain the trade-off out loud while someone adds a constraint. If you want the questions senior iOS loops ask, with weak and strong answers and the follow-ups written out, that is what iOS Interview Training is. The free sample shows the format first.
Questions readers ask
© 2026 Mike Salari, Interview Runtime. All rights reserved. Quoting a short excerpt with a link to this page is welcome; republishing the article, in full or in part, is not.
