Luma Commons mobile app programming company
    ios engineering

    Swift Concurrency in Production: What Actually Happens When You Migrate a Fintech App

    NN
    Nikhil Nangia
    April 25, 2025
    11 min read
    Xcode debugging console showing Swift async/await task hierarchy during a concurrency migration

    Swift Concurrency in Production: What Actually Happens When You Migrate a Fintech App


    TL;DR / Key Takeaways
    - @MainActor annotations silently move business logic onto the main thread, causing UI stuttering under production load. Limit @MainActor to the UI boundary only.
    - Actor reentrancy means state can change between await points inside an actor. Keep state mutations synchronous and push async work to the boundaries.
    - A concurrency migration doesn't just change APIs. It changes runtime scheduling, which exposes data races that GCD's timing accidentally hid for years.
    - Structured concurrency gives you automatic cancellation propagation, but it kills every "fire and forget" pattern in your codebase.

    The crash report came in on a Tuesday afternoon. Our fintech app, a payment platform handling thousands of transactions daily, started throwing EXC_BAD_ACCESS crashes at a rate we hadn't seen since the early days. The stack traces pointed to our transaction processing pipeline, the same pipeline we had just finished migrating from Grand Central Dispatch to Swift Concurrency two weeks earlier.


    We had been careful. We had read the documentation. We had written tests. And the migration had still broken something that only revealed itself under real production load.


    This is what we learned over six months of migrating a large iOS engineering codebase to async/await and actors. According to the 2024 iOS Developer Community Survey, roughly 62% of active Swift projects now use async/await in some form, but only about 28% have completed a full migration from GCD. The gap between adoption and completion tells you everything about how tricky this gets in practice.


    Why Does @MainActor Cause Performance Problems?


    The short answer: it funnels background work onto the main thread without telling you.


    When you start adopting Swift Concurrency, the compiler's Sendable warnings push you toward annotating things with `@MainActor`. It makes the warnings go away. The code compiles. Everything feels right.


    What we didn't fully appreciate was that `@MainActor` doesn't just mean "this is safe to use from the main thread." It means "this must run on the main thread." When you start putting `@MainActor` on view models, services, and data managers, you're funneling an enormous amount of work onto the main thread, work that was previously dispatched to background queues.


    We discovered this when our app's UI started stuttering during peak usage. Apple's WWDC 2022 session on Instruments recommends keeping main thread utilization below 80% for smooth scrolling. Ours was hitting 95%+, not with UI work, but with business logic implicitly moved to the main thread by our `@MainActor` annotations.


    The fix was to be surgical about placement:


  1. Use @MainActor: View models that directly drive UI updates, SwiftUI view properties
  2. Don't use @MainActor: Services, data transformation layers, network response parsing, repository objects
  3. Rule of thumb: @MainActor stops at the boundary between UI and business logic. Everything below that boundary runs on the cooperative thread pool

  4. Apple's own guidance from SE-0316 (Global Actors) states that global actor isolation should be applied intentionally, not as a blanket fix for Sendable warnings. We learned this the hard way.


    How Hard Is Sendable Compliance in a Real Codebase?


    It was the single most time-consuming part of the migration. We spent roughly 40% of total migration hours on Sendable conformance alone.


    Swift's Sendable protocol is the compiler's way of ensuring that data shared across concurrency domains is safe to access. For simple value types, this is trivial. Structs with value-type properties are Sendable by default. But real-world iOS apps are full of reference types: classes with mutable state, delegates, closures that capture mutable variables.


    Here's how the options break down:


    ApproachWhen to UseTrade-off
    Value types (structs)Stateless or immutable dataRequires redesign of existing classes
    Actor isolationMutable shared state (e.g., UserSession)Every access becomes `await`, cascading changes
    `@unchecked Sendable`Legacy code with manual synchronizationBypasses compiler checks, shifts burden to you
    `nonisolated(unsafe)`Truly immutable references the compiler can't verifySwift 5.10+, use sparingly

    We had a `UserSession` class used throughout the app. It held authentication state, preferences, and account information. Dozens of services depended on it. Making it Sendable meant turning it into an actor, which triggered a cascade: `let name = session.currentUser.name` became `let name = await session.currentUser.name`. Every call site. Every test. Every mock.


    According to data from Swift Evolution proposal SE-0302, the Sendable design went through multiple revisions specifically because the community flagged the adoption burden on existing codebases. That concern was well-founded.


    What Data Races Does a Concurrency Migration Expose?


    Old bugs, not new ones. The migration changes runtime scheduling, which surfaces races that GCD's timing accidentally prevented.


    The Tuesday crash from our opening was exactly this. Our transaction processing pipeline had a shared mutable dictionary accessed from multiple GCD queues. Under GCD, the specific scheduling behavior meant these accesses rarely collided. Under Swift Concurrency's cooperative thread pool, tasks could be interleaved more finely, and the race condition started firing regularly.


    This is the thing nobody tells you about a concurrency migration: you're not just adopting a new API, you're changing the runtime behavior of your entire app. Code that was accidentally safe under GCD may not be accidentally safe under Swift Concurrency.


    We ran our full fintech development test suite with Thread Sanitizer enabled for weeks. The results were sobering:


  5. 34 data races detected that had existed for 1-3 years
  6. 12 races in code we considered "well-tested"
  7. Zero were caught by our existing unit test suite without TSan enabled

  8. Apple's Xcode 15 documentation notes that Thread Sanitizer incurs roughly a 2-8x runtime slowdown, which is why most teams don't run it continuously. But during a concurrency migration, that cost is absolutely worth paying. The App Performance gains from fixing hidden races far outweigh the testing overhead.


    How Does Actor Reentrancy Break Your Logic?


    Actors prevent data races but not logic races. State can change between await points inside an actor, and the compiler won't warn you.


    The mental model most people have of actors is "one thing at a time, in order." That's not quite right. As described in SE-0306 (Actors), when an actor method hits an `await`, the actor suspends and is free to process another message. Mutable state you set before the await might be changed by another caller while you're waiting.


    We hit this in our balance-checking logic:


  9. Read the current balance
  10. Make a network call to verify it (await)
  11. Proceed with the transaction

  12. Between step 2 and step 3, another transaction could modify the balance. The actor was never accessed concurrently, but the interleaving of suspended and resumed work meant our assumptions about state consistency across await points were wrong.


    The fix pattern we now use everywhere:


  13. Keep actor-isolated state mutations synchronous and atomic
  14. Never let state reads and writes span await points
  15. Do async work outside the actor, then call a synchronous actor method to update state
  16. Think of each await point as a potential "yield" where your actor's state might change

  17. This is one of the subtlest aspects of Swift Concurrency, and the Swift Concurrency Manifesto by Chris Lattner flagged reentrancy as a deliberate design choice: the alternative (blocking the actor until the await completes) would create deadlock risks.


    How Did Structured Concurrency Change Our Architecture?


    It gave us automatic cancellation for the first time, but killed every "fire and forget" pattern in the codebase.


    Under GCD, cancellation was manual and error-prone. You'd set a flag, check it periodically, and hope every branch remembered to check. With structured concurrency, when a parent task is cancelled, all child tasks are automatically cancelled. When one task in a group throws an error, sibling tasks are cancelled too.


    This changed how we built our transaction processing pipeline:


    AspectGCD ApproachStructured Concurrency
    CancellationManual flags, checked periodicallyAutomatic propagation to all child tasks
    Error handlingProcedural, scattered across callbacksStructural, using task group throwing
    Task ownershipFire-and-forget commonEvery task has an owner with bounded lifetime
    Pipeline teardownManual cleanup in each stepParent cancellation tears down everything

    But structured concurrency forced us to rethink object lifetimes. Under GCD, firing off a background task and letting it complete independently was standard practice. With structured concurrency, every task has an owner, and that owner's lifetime bounds the task's lifetime. We had to find every "fire and forget" pattern and either give it a proper parent or explicitly use an unstructured `Task`.


    According to a 2024 Swift Forums survey thread, unstructured `Task {}` usage remains one of the most common concurrency anti-patterns in production Swift codebases. It's not always wrong, but it should make you pause and ask whether structured concurrency could work instead.


    GCD vs. Swift Concurrency: Which Should You Choose in 2025?


    Swift Concurrency is the right direction, but your migration timeline matters more than the destination.


    Here's an honest comparison based on what we observed across our fintech latency case study and other client projects:


    FactorGCDSwift Concurrency
    Learning curveModerate (queues, barriers)Steep (actors, Sendable, isolation)
    Compiler safetyNone for data racesSendable checking catches races at compile time
    CancellationManualAutomatic with structured concurrency
    DebuggingDispatch queue labels in debuggerImproved with Xcode 15+ task names
    Ecosystem readinessMature, all libraries support itGrowing; some SDKs still use completion handlers
    Runtime behaviorDispatch queues, thread-per-queue possibleCooperative thread pool, limited threads

    A 2024 JetBrains survey found that 73% of iOS developers who completed a Swift Concurrency migration reported improved code clarity, but 58% said the migration took longer than estimated. Both numbers match our experience exactly.


    Was the Migration Worth It?


    Yes, with significant caveats.


    Six months later, with the migration complete, here's the honest scorecard:


    What improved:

  18. The concurrency model is explicit where GCD was implicit
  19. Data races that hid for years have been found and fixed
  20. Cancellation works correctly for the first time
  21. New engineers can read the concurrency flow because it's in the type system, not in mental models about which queue is which
  22. Our crash rate in the transaction pipeline dropped from 0.3% to 0.01%

  23. What cost more than expected:

  24. Migration took 2x our original estimate
  25. It exposed pre-existing bugs that had to be fixed before we could continue
  26. Required rearchitecting parts of the app we hadn't planned to touch
  27. Demanded understanding of Swift Concurrency semantics well beyond what WWDC sessions cover

  28. My advice for teams starting this migration today:


  29. Don't migrate everything at once. Start with leaf nodes (utilities, services with no internal dependencies) and work inward
  30. Run Thread Sanitizer continuously for months after migration, not just during
  31. Budget for surprises. The migration will find bugs you didn't know you had, and fixing them is part of the scope whether you planned for it or not
  32. Audit @MainActor usage weekly. It creeps back in whenever someone silences a Sendable warning
  33. Keep a shared "reentrancy checklist" for code review of any actor method containing await

  34. Swift Concurrency is the right direction for the platform. The iOS engineering world is moving toward it for good reason. But "right direction" and "easy migration" are very different things. Go in with realistic timelines and the understanding that the hardest part isn't learning the new API. It's discovering what the old code was hiding.


    If you're planning a Swift Concurrency migration for a fintech or regulated app and want to avoid the mistakes we made, Luma Commons has done this work across multiple production codebases. We can help you scope it accurately, run the migration without breaking production, and handle the security and compliance implications that come with changing your concurrency model.


    Frequently Asked Questions


    Is Swift Concurrency stable enough for production fintech apps?


    Yes. As of Swift 5.10 and Xcode 15, the core concurrency features (async/await, actors, structured concurrency) are stable and used in production by major apps including Apple's own. The challenge isn't stability of the feature set. It's the complexity of migrating an existing codebase that was designed around GCD's runtime behavior.


    How long does a Swift Concurrency migration take for a mid-size app?


    For a codebase with 50,000-150,000 lines of Swift, expect 3-6 months for a full migration with a team of 2-3 engineers. Our experience, and what we've seen across multiple team augmentation engagements, is that initial estimates are typically 40-60% too low because they don't account for the hidden data races and architectural changes the migration surfaces.


    Can you adopt Swift Concurrency incrementally alongside GCD?


    Yes, and this is the recommended approach. Apple provides bridging APIs like `withCheckedContinuation` and `withCheckedThrowingContinuation` specifically for wrapping GCD-based code in async interfaces. You can migrate module by module. The key is picking the right order: start at the leaves of your dependency graph and work toward the core.


    What's the biggest risk in a Swift Concurrency migration?


    Actor reentrancy and hidden data races, not the API surface itself. Most teams can learn async/await in a week. The production failures come from assumptions about state consistency across await points inside actors, and from GCD-era data races that only surface under the cooperative thread pool's different scheduling behavior. Running Thread Sanitizer continuously is non-negotiable.


    Should new iOS projects start with Swift Concurrency or GCD?


    New projects should use Swift Concurrency from day one. There's no reason to adopt GCD for greenfield work in 2025. The compile-time Sendable checking, structured cancellation, and actor isolation are strictly better foundations than dispatch queues. The pain points described in this post are migration-specific problems, not problems with Swift Concurrency itself.

    Did you find this useful?
    Swift Concurrency
    async/await
    iOS Engineering
    Fintech Mobile Development
    App Performance
    NN

    Nikhil Nangia

    Founder & Seasoned iOS Expert

    Seasoned iOS expert with 9+ years of experience building fintech, regulated, and consumer mobile products. Nikhil specializes in Swift, app architecture, and technical due diligence for pre-acquisition reviews.