Concurrency in Swift has shifted dramatically with async/await and Actors, introduced in Swift 5.5 and refined since. For developers building modern iOS and macOS apps, these features promise safer, more readable code compared to callbacks and manual dispatch queues. But moving from old patterns to new ones isn't just a syntactic swap—it changes how we reason about state, timing, and data races. This article walks through the practical decisions you'll face when adopting Swift's structured concurrency, focusing on workflow comparisons and conceptual trade-offs rather than just API listings.
Where async/await and Actors Show Up in Real Work
In typical app development, concurrency surfaces in a few predictable places: network requests, database reads and writes, file I/O, and heavy computations that must not block the main thread. Before async/await, we used completion handlers or delegates, often nesting callbacks into pyramid-like structures. With async/await, these same operations become linear sequences that read like synchronous code. For example, fetching user data from an API, then loading a profile image, then updating the UI can be written as three sequential await calls inside a single function.
Actors solve a different problem: protecting mutable shared state from data races. In Swift, an actor ensures that its properties and methods are accessed serially, even from multiple tasks. This changes how we approach models like caches, user session managers, or real-time data streams where multiple concurrent operations might try to mutate the same object. The compiler enforces actor isolation, catching potential races at compile time rather than at runtime.
But the real world is messier. You might have a legacy codebase using GCD queues, or a team that relies on Combine for reactive streams. Deciding where to introduce async/await and actors requires understanding not just the new APIs, but also how they interact with existing patterns. For instance, bridging an async function into a Combine publisher is straightforward, but the reverse—running a Combine pipeline inside a task—requires careful handling of cancellation and lifetimes.
Another common scenario is the main thread. UIKit and SwiftUI expect UI updates on the main actor. With async/await, you can mark a function as @MainActor, ensuring it runs on the main thread without manually dispatching. This reduces boilerplate and eliminates a whole class of bugs where developers forget to dispatch back to the main queue after a background operation. However, over-marking everything as @MainActor can lead to unnecessary main-thread contention, so judgment is needed.
We also see async/await in SwiftUI views, where .task modifiers let you start asynchronous work when a view appears and automatically cancel it when the view disappears. This is much cleaner than manually managing onAppear and onDisappear with Combine or GCD. The key insight is that async/await doesn't replace all concurrency patterns—it replaces the need for explicit threading in many cases, but you still need to think about where work happens and how to cancel it.
Real Project Example: A Photo Feed App
Consider a photo feed app that loads thumbnails from a server, caches them locally, and displays them in a collection view. With async/await, you can write a function that fetches metadata, then downloads images in parallel using a task group, then updates the UI on the main actor. Each step is a clear await, and cancellation is handled automatically if the user scrolls away. Before async/await, this required multiple nested completions, manual cancellation flags, and careful dispatch to the main queue. The new approach reduces cognitive load and makes the flow explicit.
Foundations Readers Often Confuse
One of the most common misunderstandings is that async/await makes code run in parallel. In reality, async/await is about structured concurrency—a way to write concurrent code that still follows a linear flow. A function marked async can suspend itself at an await point, yielding the thread to other tasks, but it does not automatically create a new thread. Parallelism requires explicit task creation, such as async let or task groups.
Another confusion is the difference between actors and other synchronization primitives like locks or serial queues. Actors provide compiler-enforced isolation: you cannot access an actor's mutable state from outside without going through its interface, and the actor ensures that only one task executes inside it at a time. This is similar to a serial dispatch queue, but the compiler can check correctness. However, actors are not a silver bullet—they can introduce deadlocks if you call an actor's method from within itself in a reentrant way, though Swift's actors are reentrant by default, meaning they can suspend and allow other tasks to run, which can lead to subtle state changes.
Task cancellation is another area of frequent confusion. Calling task.cancel() does not stop a task immediately; it only sets a cancellation flag. The task must check for cancellation using Task.checkCancellation() or Task.isCancelled and then stop its work. This cooperative model means you need to design your async functions to be cancellation-aware, especially in long-running operations like loops or file downloads. Many developers new to structured concurrency assume cancellation is automatic, leading to tasks that continue running despite being cancelled.
Finally, the relationship between async/await and Combine is often misunderstood. Combine is a reactive framework for handling asynchronous events over time, like UI bindings or notifications. Async/await is for one-shot async work. They complement each other: you can convert a Combine publisher into an async sequence using values, or run an async function inside a Combine pipeline using flatMap with a task. But they are not interchangeable—choose Combine when you need a stream of values, and async/await when you need a single result or a linear sequence of operations.
Structured Concurrency vs. Unstructured Tasks
Swift supports both structured concurrency (task groups, async let) and unstructured tasks (Task { } and Task.detached). Structured concurrency ensures that child tasks are completed before the parent finishes, and cancellation propagates automatically. Unstructured tasks can outlive their parent scope, which is useful for fire-and-forget operations but makes lifetime management harder. Knowing when to use each is crucial: prefer structured concurrency for work that is part of a larger unit (like loading data for a view), and unstructured tasks for long-running background work like periodic syncs.
Patterns That Usually Work
Over the past few years, the Swift community has converged on several patterns that reliably produce clean, maintainable concurrent code. The first is using async let for independent tasks that can run in parallel. For example, fetching user profile and user settings from two different endpoints can be written as async let profile = fetchProfile() and async let settings = fetchSettings(), then await (profile, settings) to get both results. This is more readable than manually creating a task group and merging results.
Another proven pattern is using actors for shared mutable state, especially in models that need to be thread-safe. For instance, a cache actor that stores downloaded images can be accessed from any task, and the actor ensures that writes and reads are serialized. The compiler prevents accidental direct access to the cache's internal dictionary, reducing data races. However, actors have overhead—each method call involves a hop to the actor's executor—so for performance-critical code, you might use a different approach like os_unfair_lock or a custom serial queue, but for most app-level state, actors are sufficient and safer.
Using @MainActor on view models and UI-related classes is a pattern that eliminates a whole class of bugs. By marking a class or a method as @MainActor, you ensure that all its code runs on the main thread, without needing to dispatch manually. This works well with SwiftUI's @StateObject and @ObservedObject, where property changes trigger UI updates. The compiler will warn you if you try to call a non-main-actor method from a main-actor context, catching mistakes early.
Task groups are ideal for dynamic parallelism, where you don't know the number of tasks at compile time. For example, processing an array of URLs to download files can be done with a task group that spawns a child task for each URL, then collects results. The group ensures that all child tasks finish before the group returns, and if any child throws an error, the group propagates it. This pattern replaces old approaches like DispatchGroup with a more structured and type-safe API.
Finally, using AsyncSequence with for await is a clean way to handle streams of values, like notifications, location updates, or data from a web socket. Instead of subscribing to a callback and manually managing cleanup, you can iterate over an async sequence, and the loop automatically suspends when waiting for the next value. This integrates well with SwiftUI's .task modifier, which can start an async loop and cancel it when the view disappears.
Pattern for Error Handling
Error handling in async/await is straightforward: you use do-catch blocks just like synchronous code. However, one nuance is that task groups only propagate the first error by default. If you want to continue collecting results despite individual failures, you need to handle errors inside each child task and return an optional or a result type. This is a common pattern for batch operations where partial success is acceptable.
Anti-Patterns and Why Teams Revert
Despite the benefits, some teams have reverted to older concurrency models after encountering pitfalls. One anti-pattern is using unstructured tasks (Task { }) for everything, especially within SwiftUI views. Unstructured tasks can outlive the view's lifetime, leading to memory leaks and updates on deallocated objects. The fix is to use .task or .onAppear with structured concurrency, which ties the task's lifetime to the view's appearance.
Another anti-pattern is marking every single function as async even when it doesn't need to suspend. This forces callers to use await unnecessarily and can make code harder to read. A function should be async only if it actually performs asynchronous work, like network calls or file I/O. If it just wraps a synchronous computation, keep it synchronous and call it from an async context if needed.
Overusing actors is another trap. Actors are great for protecting state, but they introduce a performance cost for every method call. If you have a simple value type that doesn't need isolation, using an actor adds overhead without benefit. Additionally, actors can cause deadlocks if you have cyclic dependencies or if you call a synchronous method on an actor from within the same actor's async method (though Swift's reentrancy helps, it can still lead to unexpected behavior). Some teams have reverted to using simple structs with @Atomic wrappers or custom locks for performance-critical paths.
A subtle anti-pattern is ignoring task cancellation. In long-running operations like loops or file processing, failing to check for cancellation can lead to wasted work and unresponsive UI. The fix is to sprinkle try Task.checkCancellation() at strategic points, or use withTaskCancellationHandler to perform cleanup when cancelled. Teams that skip this often find that their app continues to perform background work even after the user has navigated away, draining battery and memory.
Finally, mixing async/await with GCD without understanding the threading implications can cause issues. For example, calling await inside a DispatchQueue.async block can lead to unexpected suspension on a background queue. It's better to use Swift's structured concurrency throughout, or clearly separate responsibilities. Some teams have reverted to pure GCD for certain backend services because they found the actor model too restrictive or the performance overhead too high for high-throughput operations.
The Silent Reversion to Callbacks
In some cases, teams have reverted to completion handlers for operations that need fine-grained control over threading or when integrating with C libraries. Async/await works well within the Swift ecosystem, but when bridging to Objective-C or C code, the overhead of creating continuations can be non-trivial. For these edge cases, a callback-based wrapper might be simpler, though it breaks the consistency of the codebase.
Maintenance, Drift, and Long-Term Costs
Adopting async/await and actors is not a one-time effort. Over time, codebases can drift into a mix of old and new patterns, especially if multiple developers work on different parts of the app. A common long-term cost is the need to maintain two concurrency systems: GCD for legacy code and structured concurrency for new code. This increases cognitive load and the risk of subtle bugs when bridging between them.
Another maintenance cost is keeping up with Swift evolution. Swift 6 introduces strict concurrency checking by default, which will flag potential data races at compile time. Code that was written for Swift 5.5 may need adjustments to comply with the new checks, such as adding Sendable conformance to types passed across concurrency boundaries. This can be a significant refactoring effort for large projects.
Actors also introduce a new kind of performance cost: actor hopping. Each call to an actor's method involves a context switch to the actor's executor, which can add latency. If you have a chain of actor calls, the overhead can accumulate. Profiling is essential to identify hot paths where actors might be slowing things down. In some cases, you might need to batch operations or use a non-actor approach for performance-critical sections.
Testing concurrent code is another area where costs add up. Async tests are now supported with XCTest and await, but testing actor isolation and cancellation behavior requires careful setup. You may need to write tests that wait for specific states or use expectations. The learning curve for testing async code is real, and teams often under-test concurrency paths, leading to bugs that only appear in production under load.
Documentation and code review also become more complex. Reviewers need to understand structured concurrency to spot issues like missing cancellation checks or incorrect actor isolation. Without proper training, teams may approve code that looks correct but contains subtle races or deadlocks. Investing in team education and coding standards is a long-term cost that pays off in reduced bugs.
Refactoring Legacy Code
Refactoring a large codebase from callbacks to async/await is typically done incrementally, but each conversion introduces risk. A function that was synchronous may become async, forcing all callers to change. This can ripple through the entire app. A safer approach is to start with new features using async/await and gradually convert old code when it's touched for other reasons. The cost of this piecemeal approach is that the codebase remains in a hybrid state for months or years.
When Not to Use This Approach
Async/await and actors are not the right choice for every situation. For real-time systems with strict latency requirements, the overhead of task suspension and actor hopping can be unacceptable. In audio processing, game loops, or high-frequency trading, you might need manual thread management and lock-free data structures. Swift's structured concurrency is designed for app-level responsiveness, not microsecond-level determinism.
Similarly, for simple synchronous scripts or command-line tools that don't need concurrency, adding async/await just adds complexity. If your program runs sequentially from start to finish without waiting for I/O, keep it synchronous.
Another case is when integrating with a large existing codebase that heavily uses GCD and custom queues. A full migration to async/await might be too disruptive. It's often better to wrap legacy code in async wrappers at the boundaries, rather than rewriting everything. For example, you can use withCheckedContinuation to bridge a GCD-based API into an async function, allowing new code to use async/await while the old code remains unchanged.
For operations that need fine-grained control over thread priority or QoS, GCD still offers more direct control. Async/await uses an implicit executor based on the current task's priority, but you cannot easily pin a task to a specific queue. If you need to run work on a background thread with low priority, you might still use DispatchQueue.global(qos: .background).async. However, you can achieve a similar effect by creating a custom executor, but that is more advanced.
Finally, if your team is not yet comfortable with structured concurrency, forcing adoption can lead to more bugs than it solves. It's better to invest in training and start with small, non-critical features. The learning curve is real, and the benefits only appear when the team uses the patterns correctly.
When Actors Add Friction
Actors are not ideal for high-contention scenarios where many tasks try to access the same actor simultaneously. The serial execution can become a bottleneck. In such cases, consider using an actor with a custom executor that allows some parallelism, or redesign the data model to reduce contention. For example, instead of a single cache actor, use a sharded cache with multiple actors, each responsible for a subset of keys.
Open Questions and FAQ
Many developers still have open questions about the practical use of Swift's concurrency model. Here are some of the most common ones.
How do I test async code effectively?
XCTest supports async test methods directly. You can mark a test function as async and use await inside it. For testing actor behavior, you may need to create multiple tasks and use expectations to wait for certain states. Be mindful of test timeouts, as async tests can hang if cancellation or error handling is incorrect.
Should I use actors or locks for thread safety?
In general, prefer actors for most app-level state. They are safer because the compiler enforces isolation. Locks are harder to use correctly and can lead to deadlocks. However, for performance-critical code with low contention, a simple lock might be faster. Measure before optimizing.
How does async/await interact with Combine?
You can convert a Combine publisher to an async sequence using the values property, and you can run an async function inside a Combine pipeline using flatMap with a Task. However, be careful with cancellation: a Combine subscription may not automatically cancel the underlying task. Use handleEvents or cancel to manage lifetimes.
What is the best way to handle errors in task groups?
By default, a task group throws when any child task throws. If you want to continue on error, catch the error inside the child task and return an optional or a result type. Then collect all results and handle failures at the group level.
Will Swift 6 break my existing async code?
Swift 6 introduces strict concurrency checking, which may require you to add Sendable conformance to types that are passed across concurrency boundaries. Most code written for Swift 5.5+ will compile with warnings, but you'll need to address them to fully adopt Swift 6. Start by auditing types that are shared between tasks.
Summary and Next Experiments
Swift's async/await and actors provide a safer, more readable way to write concurrent code, but they require a shift in mindset. The key takeaways: use structured concurrency (task groups, async let) for work that is part of a larger unit; use actors for shared mutable state; use @MainActor for UI-bound code; always handle cancellation; and avoid mixing concurrency models without clear boundaries.
To deepen your understanding, try these experiments: (1) Convert an existing completion-handler-based network layer to async/await and measure the change in readability and crash rates. (2) Implement a simple cache actor and test it with multiple concurrent tasks to see how actor isolation works. (3) Use a task group to download multiple files in parallel and handle partial failures. (4) Profile an actor-heavy code path to see if actor hopping is a performance bottleneck. (5) Enable Swift 6 strict concurrency checking in a sample project and fix all warnings to understand the migration effort.
Concurrency is a deep topic, and Swift's model is still evolving. Stay curious, experiment in small projects, and share your findings with the community. The payoff is code that is easier to reason about and less prone to mysterious crashes.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!