Ghost Car is a SwiftUI plus SwiftData app, which means most of it genuinely wants to live on the main actor. Views update their UI there, and SwiftData model types are required to be there. The catch is that comparing two drives means aligning thousands of GPS coordinates and computing distances and bearings between them, and that work has no business running on the thread the user is staring at.
The pile of warnings
I turned on default main actor isolation for the project. It felt like a sensible default for an app that is mostly UI: it spares you from annotating every view and view model with @MainActor, and it lines the codebase up with where Swift is heading. The moment I flipped it on, the compiler lit up.
The complaints all pointed at the same place: the comparison engine. That code aligns two recordings on a shared timeline, walks both coordinate arrays to find where the paths split or rejoin, and trims idle time at each end where the car was barely moving. With main actor isolation applied by default, every function in that file was now main-actor-bound, and calling them from a background context produced isolation diagnostics. Under Swift 5 they were warnings. Under Swift 6 they become hard errors. So I was looking at something that would refuse to build the day I bumped the language version.
The tempting wrong turns
There were two obvious ways to quiet the compiler, and I think both would have been worse than the problem itself.
- Spin up a dedicated actor to own the comparison work. That isolates the math, but it also drags every call site into async territory and introduces a new piece of shared mutable state to reason about, for code that holds no state at all.
- Scatter
Taskblocks around the call sites to hop off the main actor and back. That hides the work without explaining it, and it sprinkles concurrency plumbing through views I would rather keep simple.
Both approaches treated a labeling problem as if it were a threading problem. The engine does not need protection from concurrent access, because it does not touch any shared state. It is a set of static methods that take coordinate arrays in and return segment math out. The honest fix was to say exactly that to the compiler.
Nonisolated did most of the work
Swift already has a precise word for code that belongs to no actor: nonisolated. I marked the pure comparison and detection methods as nonisolated, which is a promise that they touch no UI state and no model state, so they are safe to run from anywhere. The alignment entry point, the divergence and convergence detection, the idle trimming, and the Haversine distance and bearing helpers all got the same treatment.
// Pure segment math. No UI, no @Model mutation.
nonisolated static func alignDrives(
_ drives: [Drive],
settings: ComparisonSettings
) -> [DriveSegment] {
// build segments, trim idle ranges,
// then align on a shared timeline by mode
}
nonisolated static func distance(
from: CLLocationCoordinate2D,
to: CLLocationCoordinate2D
) -> Double {
let a = CLLocation(latitude: from.latitude, longitude: from.longitude)
let b = CLLocation(latitude: to.latitude, longitude: to.longitude)
return a.distance(from: b)
}This worked because the engine was already written as pure functions. Earlier discipline paid off: I had kept the comparison logic independent of the SwiftUI and map layers so I could test it on its own, and the same separation that made it testable made it trivial to declare nonisolated. Code with no dependency on UI or storage is, almost by definition, code that does not care which actor it runs on.
The two exceptions that made it interesting
If every change had been a clean nonisolated, this would be a boring story. Two spots needed more thought, because they sat right on the boundary between the pure math and the SwiftData model.
The first was an enum the engine uses to say whether it is trimming the start or the end of a drive. Once it became nonisolated, the compiler complained that its synthesized Equatable conformance was still main-actor-isolated, so comparing two values across actors was unsafe. The fix was to mark the enum Sendable and write the equality operator out by hand as nonisolated rather than letting the compiler synthesize it:
enum TrimPosition: Sendable {
case start
case end
nonisolated static func == (lhs: TrimPosition, rhs: TrimPosition) -> Bool {
switch (lhs, rhs) {
case (.start, .start), (.end, .end): return true
default: return false
}
}
}The second exception was the genuinely tricky one. A DriveSegment is a lightweight value that points at a Drive, which is a SwiftData @Model and therefore main-actor-isolated. To run alignment in the background, the segment has to carry that Drive reference across the actor boundary, but a @Model is not Sendable. The compiler is right to object in general. In this specific case I only ever read coordinates and duration off the drive, never mutate it, and I had already marked those properties nonisolated so they decode from immutable stored data. So I marked the stored reference nonisolated(unsafe):
struct DriveSegment: Identifiable {
nonisolated(unsafe) let drive: Drive // read-only access to a @Model
var startIndex: Int
var endIndex: Int
var timeOffset: TimeInterval
// ...
}The (unsafe) part matters. It is me telling the compiler that I have checked this myself and it should stop checking. That is a real promise, not a free pass, and it is only safe here because access to the drive from the background is strictly read-only against data that never changes during a comparison.
The call site, where work actually moves off the main thread
Marking functions nonisolated makes them callable from a background context. It does not, by itself, move anything off the main thread. That happens at the call site. The comparison settings sheet was the worst offender: it ran the full alignment up front to decide which alignment modes were even possible for the selected drives, and that froze the sheet for a second or two before it appeared.
The fix there was a small pattern worth remembering. I switched the view from .onAppear to .task so it could await async work, seeded the mode list with the two modes that are always available, then computed the rest on a detached task. The important detail is capturing the values the task needs before crossing the boundary, so the closure is self-contained and does not reach back into main-actor state:
@State private var availableModes: Set<ComparisonMode> = [.startLocation, .endLocation]
private func detectAvailableModes() async {
let drivesCapture = drives // capture before crossing actors
let settingsCapture = localSettings
let detected = await Task.detached(priority: .userInitiated) {
let segments = DriveComparisonEngine.alignDrives(drivesCapture, settings: settingsCapture)
// probe for divergence and convergence, build the mode set
return modes
}.value
availableModes = detected // back on the main actor
}The sheet now opens instantly with the basic modes, and the extra modes light up a moment later once the background pass finishes.
What stayed exactly where it belonged
The goal was never to move everything off the main actor. It was to move only the part that should leave, and leave everything else untouched.
- The views stayed on the main actor, because that is where SwiftUI updates the screen.
- The SwiftData
@Modeltypes stayed on the main actor, because@Modelrequires it. - The comparison math became nonisolated, free to run off the main thread, then hand its result back to the UI to draw.
The compiler was not asking me to change the architecture. It was asking me to label it accurately, and to take responsibility for the one place it could not verify on its own.
The payoff
The cleanup cleared every concurrency warning in the project and made Ghost Car forward-compatible with Swift 6 without changing how the app is built. The heavy alignment runs off the main thread, so opening the comparison sheet and scrubbing through a comparison stay responsive even with large recordings. No new actors, no scattered tasks in the views, no restructuring of how drives flow from storage into a comparison and onto the map.
A few things I am taking forward:
- Turn on default main actor isolation early, while the diagnostics are still warnings and not a migration deadline staring you down.
- Pure functions that take values and return values are the easiest code to make concurrency-safe, so keep computation separate from UI and storage from the start.
- Reach for the smallest accurate annotation before reaching for new machinery.
nonisolatedusually beats a new actor, and an explicitSendableconformance or hand-written operator often beats fighting a synthesized one. - Treat
nonisolated(unsafe)as a real promise. It is fine when access is genuinely read-only against unchanging data, and a trap if you reach for it just to silence the compiler.