I'll be honest—migrating to Swift 6's strict concurrency nearly broke me.

Three apps, six months, and more "Thread 1: Fatal error" crashes than I care to admit. Apple's documentation is great at explaining the syntax, but it doesn't prepare you for the 2 AM debugging sessions where everything compiles perfectly — and your app still crashes randomly.

Here's what I wish someone had told me before I started.

1. @MainActor Isn't a Magic Thread-Safety Wand

I thought I understood @MainActor.

“Run stuff on the main thread, update UI safely — got it.”

Everything passed testing, code reviews looked great — and then production revealed the hidden race conditions.

Swift
@MainActor
class ViewModel: ObservableObject {
    @Published var data: [Item] = []

    func loadData() async {
        // This compiles, but creates subtle timing issues
        let result = await networkService.fetch()
        self.data = result
    }
}

The problem?

The problem? If networkService captures self and calls back asynchronously, you've created a race condition even though everything "looks" safe.

I kept staring at the code thinking "but it's all @MainActor!" Turns out, @MainActor protects synchronous access. The moment you write await all bets are off until that call returns.

Lesson learned: Think of await as "pause button + anything can happen." Between suspension points, your carefully isolated state might not be so isolated

2. Sendable Compliance: Or How I Learned to Stop Worrying and Accept the Refactor

Swift 6 wants your types to be Sendable to safely cross between tasks. Sounds reasonable until you actually try it.

My "simple" User model had a callback property. Can't make it Sendable. Okay, I'll refactor that one thing. Except that callback is used in 15 places. And those places have their own closures. And suddenly I'm three layers deep questioning my entire architecture.

Here's what saved my sanity: I stopped trying to do it "right" immediately.

I used @unchecked Sendable liberally. Yes, the compiler warnings went away. Yes, I felt a bit dirty. But you know what? The app compiled, I could test things, and I created a proper backlog to fix each one methodically.

Trying to be a purist from day one is how you end up in refactoring hell for months. Sometimes "working with tech debt documented" beats "perfect but never ships."

3. Actor Reentrancy: The Bug That Looks Like Clean Code

This one genuinely surprised me. I'd been programming for years, understood threads and locks, felt pretty confident. Then actors humbled me.

Swift
actor DataManager {
    private var cache: [String: Data] = [:]

    func getData(for key: String) async -> Data {
        if let cached = cache[key] {
            return cached
        }

        let data = await fetchFromNetwork(key)
        cache[key] = data  // 🚨 BUG HIDING HERE
        return data
    }
}
Does it work? Yes. Is it elegant? Not really.
This was my attempt at being “clever” by checking the cache again after the network call — thinking it would avoid redundant writes. It helped a bit, but it still led to multiple concurrent fetches for the same key.
A better approach (thanks to Esteban Triviño for pointing this out) is tracking in-flight requests:
Swift
// My original attempt: the infamous "check twice" hack
actor DataManager {
    private var cache: [String: Data] = [:]

    func getData(for key: String) async -> Data {
        if let cached = cache[key] {
            return cached
        }

        let data = await fetchFromNetwork(key)

        // Check again in case another task already fetched and cached it
        if let cachedAgain = cache[key] {
            return cachedAgain
        }

        cache[key] = data
        return data
    }
}

A better approach (thanks to Esteban Triviño for pointing this out) is tracking in-flight requests:

Swift
actor DataManager {
    private var cache: [String: Data] = [:]
    private var inflightRequests: [String: Task<Data, Error>] = [:]

    func getData(for key: String) async throws -> Data {
        if let cached = cache[key] { return cached }

        // If someone's already fetching this, just wait for them
        if let existingTask = inflightRequests[key] {
            return try await existingTask.value
        }

        // Start a new fetch
        let task = Task { try await fetchFromNetwork(key) }
        inflightRequests[key] = task

        do {
            let data = try await task.value
            cache[key] = data
            inflightRequests[key] = nil
            return data
        } catch {
            inflightRequests[key] = nil
            throw error
        }
    }
}

This deduplicates concurrent requests entirely—much better than my "check twice" hack. But honestly? I shipped the simpler version first and refactored later. Sometimes "works now" beats "perfect eventually."

4. Testing: Where Confidence Goes to Die

I'm just going to say it: testing concurrent code made me feel like I'd forgotten how to program.

Tests that passed locally failed in CI. Tests that passed in CI failed on my colleague's machine. I had one test that would fail exactly once every 10 runs, which is basically the universe mocking you.

What actually helped:

Drop XCTestExpectation for async/await. Just use await in your test functions directly. The old expectation-based approach feels wrong because it is wrong for structured concurrency. It's like trying to test a Tesla with tools designed for a Model T.

Add Task.yield() strategically to force suspension points. It's like shaking your code to see what falls out. Sometimes the only way to catch a race condition is to deliberately create more opportunities for tasks to interleave.

Write stress tests. Run 100 concurrent operations and see what breaks. If something's going to fail, make it fail loudly and consistently rather than occasionally.

Here's a stress test that saved me multiple times:

Swift
func testConcurrentAccess() async throws {
    let manager = DataManager()

    // Hammer it with 100 concurrent requests
    await withThrowingTaskGroup(of: Data.self) { group in
        for _ in 0..<100 {
            group.addTask {
                try await manager.getData(for: "test")
            }
        }
    }

    // If this doesn't crash, you're probably okay
    // (but no promises)
}

The "(but no promises)" is sadly not a joke. Concurrent bugs are slippery. Sometimes they only show up under production load with real network latency and user behavior you can't simulate.

5. "Almost Done" Syndrome

Here's the thing they don't tell you in the WWDC videos: you're never actually "done" migrating to Swift 6 concurrency.

You'll fix one module, high-five yourself, then discover your analytics SDK uses global mutable state. Or that beloved third-party library you've used for years? Not Sendable-compliant. And rewriting it would take longer than the heat death of the universe.

I had a particularly painful moment with a popular networking library that worked great for five years. Then Swift 6 happened. The maintainer hadn't updated it. Fork it myself? Migrate to something else? Wrap it in so much @preconcurrency that it looks like a Christmas present?

I learned to embrace imperfection:

Enable strict concurrency one module at a time. Don't try to boil the ocean. Pick your most critical module, fix it properly, ship it, breathe. Then the next one.

Write thin async wrappers around legacy code instead of rewriting everything. Your future self will thank you. Sometimes a 20-line adapter is smarter than a 2-week refactor.

Document every @preconcurrency import with a ticket. Accept that some will live longer than you'd like. I have a Jira board called "Concurrency Debt" and I'm not ashamed of it.

The goal isn't perfection. It's working software that's incrementally better than yesterday.

What I'd Tell My Past Self

Swift Concurrency is genuinely great. It makes concurrent code more readable and catches entire classes of bugs at compile time. But it's not a free lunch.

This migration will surface every questionable architectural decision you've ever made. Global state you've been ignoring? Can't ignore it anymore. That "temporary" callback-based API from 2019? Yeah, about that...

The good news? Once you get through it, your code is legitimately safer. The compiler catches things that used to be production crashes. You sleep better at night. I haven't seen a "accessed on the wrong thread" crash in months.

The bad news? Getting there requires accepting that you'll feel incompetent more often than you'd like. Some days I felt like I was learning iOS development all over again. That imposter syndrome you thought you'd conquered? It comes back for a visit.

But here's the thing: that feeling means you're learning. You're not incompetent—you're adapting to a fundamentally different concurrency model. Give yourself permission to struggle with it.

What's your biggest Swift Concurrency war story? I'd love to hear what tripped you up—chances are I've been there too (or I'm about to be). Drop a comment and let's commiserate.

Next up: "The @preconcurrency Import: Tactical Retreat or Technical Debt?" Hit follow so you don't miss it.

☕ Enjoying Swift in Practice? It's free and reader-supported. If an edition saved you some debugging time, you can buy me a coffee: https://buymeacoffee.com/swiftinpractice

Originally published as a Swift in Practice edition on LinkedIn. Comments and discussion happen there.