Learn how to architect modern Apple apps with Swift concurrency, tasks, actors, and Sendable for safer, more responsive code.

Modern Apple applications are increasingly asynchronous. A single screen might load data from several APIs, process images, update a local database, respond to user interaction, and synchronize state in the background—all while the UI must remain responsive. Swift concurrency provides a modern way to manage that complexity through `async/await`, structured concurrency, tasks, actors, and `Sendable`. But using these features effectively is about much more than replacing completion handlers with `await`. The real shift is architectural: developers must understand where mutable state lives, which work belongs on the main actor, how tasks are scoped, how cancellation propagates, and how data safely moves between concurrent contexts. When those boundaries are designed well, Swift concurrency can eliminate entire categories of race conditions while making asynchronous code dramatically easier to read and maintain.
As applications become more connected and feature-rich, asynchronous work becomes unavoidable.
Imagine a shopping screen that needs to:
Load User
+
Load Recommendations
+
Load Inventory
+
Load Product Details
↓
UIAt the same time, the user can:
Navigate away
Refresh
Change filters
Cancel an operation
Start another request
Without a structured concurrency model, this can quickly become difficult to reason about.
Traditional callback-based code often becomes:
Request
↓
Completion
↓
Another Request
↓
Completion
↓
Update UI
↓
Handle Error
↓
Check CancellationSwift concurrency provides a cleaner model:
Request
↓
await
↓
Request
↓
await
↓
Update UIThe code reads much more like the business logic.
But readability is only part of the benefit.
Swift concurrency also gives the compiler and runtime more information about ownership, isolation, cancellation, and safe data transfer.
Consider the conceptual difference.
fetchUser { user in
fetchOrders(user) { orders in
updateUI(orders)
}
}As workflows grow, nesting and error handling can become difficult.
With structured concurrency:
let user = try await fetchUser()
let orders = try await fetchOrders(user)
updateUI(orders)The sequence is obvious.
Error propagation is also easier to reason about.
try await operation()can naturally propagate failure to the appropriate caller.
This encourages a much cleaner separation:
Async Operation
↓
Result / Error
↓
CallerAn `async` function can suspend while waiting for asynchronous work.
For example:
func loadProfile() async throws -> ProfileThe important word is suspend.
When the function reaches:
await loadProfile()the current task can suspend while the operation completes.
This does not mean the underlying thread simply sits there doing nothing.
That distinction matters.
Swift concurrency separates:
Tasks
from:
Threads
A task represents asynchronous work.
The system determines how that work is scheduled onto available execution resources.
A task represents a unit of asynchronous work.
Conceptually:
Task
│
├── Operation A
├── await
├── Operation B
└── ResultStructured concurrency encourages tasks to have clear lifetimes.
Instead of creating background work and forgetting about it:
Start Task
↓
Forget Task
↓
??? the work should generally have an identifiable owner.
This improves:
Cancellation
Error propagation
Resource management
Lifecycle reasoning
Sometimes you genuinely need work that is not naturally nested inside the current operation.
For example:
User Action
↓
Start Background WorkSwift provides task APIs for these cases.
But unstructured tasks should be used deliberately.
If every function creates independent tasks, the application can quickly become difficult to reason about:
Task
├── Task
│ └── Task
├── Task
└── TaskAsk:
Who owns this task, and when should it stop?
If there is no clear answer, the concurrency design probably needs another look.
One of the strongest advantages of structured concurrency is the ability to perform independent operations concurrently.
Suppose a dashboard needs:
User Profile
Orders
Recommendations
NotificationsIf these operations do not depend on each other, executing them sequentially wastes time.
Instead:
Dashboard
│
┌─────────┼─────────┐
▼ ▼ ▼
Profile Orders Recommendations
│ │ │
└─────────┼─────────┘
▼
RenderTask groups allow multiple child tasks to be managed together.
The application can express:
Run these operations concurrently, collect their results, and keep them within this scope.
That is much safer than manually creating and tracking multiple threads.
Concurrency should not be added simply because it is available.
Consider:
10,000 Tasks
↓
DatabaseIf the database can only efficiently handle a small number of concurrent operations, more concurrency may make the system slower.
The right question is:
Which operations can safely and usefully run concurrently?
Not:
How can we run everything in parallel?
Good concurrency is controlled concurrency.
Actors are one of Swift's most important concurrency features.
The core problem they solve is shared mutable state.
Imagine:
Task A ──┐
├──► Shared State
Task B ──┤
└──► Race Condition?Two tasks accessing mutable state concurrently can produce unpredictable results.
Actors provide isolation.
Conceptually:
Task A ──┐
▼
Actor
▲
Task B ──┘The actor protects its isolated state from unsafe concurrent access.
Instead of every caller directly modifying shared state:
Global Mutable Statethe state belongs to the actor:
Actor
├── State
├── Methods
└── InvariantsAccess to that state follows actor isolation rules.
It is tempting to think:
An actor is just a class with a lock.
That is too simplistic.
Actors provide a language-level isolation model.
The compiler can reason about access to actor-isolated state.
This moves part of concurrency correctness from runtime debugging into compile-time checking.
That is powerful because race conditions are notoriously difficult to reproduce.
Consider a cache:
ImageCache
├── Cached Images
├── Read
├── Insert
└── RemoveMultiple tasks may need to access it.
Instead of:
Global Dictionary
↓
Manual Lockingan actor can own the state:
ImageCache Actor
↓
Protected DictionaryNow the ownership boundary is explicit.
This is often much easier to maintain than scattered locking logic.
Apple platform applications have one especially important actor:
@MainActorUI-related state generally belongs on the main actor.
Conceptually:
Background Work
↓
Fetch / Process
↓
Main Actor
↓
Update UIFor example:
Load Data
↓
await
↓
Update View StateThe main actor ensures UI-related mutations happen within the appropriate isolation domain.
A common mistake is:
Entire Application
↓
@MainActorThat can defeat the purpose of concurrency.
If CPU-heavy work runs on the main actor:
Heavy Processing
↓
Main Actor
↓
UI Responsiveness DropsInstead, isolate UI state while allowing appropriate background work to run elsewhere.
For example:
@MainActor
View Model
↓
Async Service
↓
Background WorkThe UI remains isolated without making the entire system UI-bound.
Actors solve one side of concurrency:
Who owns mutable state?
`Sendable` helps address another:
What data can safely move between concurrency domains?
Conceptually:
Actor A
│
│ Sendable Data
▼
Actor BA type that conforms appropriately to `Sendable` communicates that its values can safely cross concurrency boundaries.
This becomes increasingly important as Swift's concurrency checking becomes stricter.
The compiler can help identify cases where data may be unsafe to transfer.
Swift's value semantics make many concurrency designs easier.
For example:
struct Productcan be passed between tasks without sharing mutable identity in the same way as a mutable reference type.
This leads to a useful design principle:
Prefer immutable values at concurrency boundaries whenever practical.
Instead of passing a mutable object through multiple actors, consider creating a value representing the data required by the receiving task.
Users cancel things constantly.
They:
Navigate away
Close a screen
Start a new search
Refresh
Change filters
If a task continues doing unnecessary work after its result is no longer relevant, the application wastes resources.
Swift concurrency provides cooperative cancellation.
Conceptually:
User Leaves Screen
↓
Task Cancelled
↓
Operation Notices Cancellation
↓
Work StopsThe important word is cooperative.
Cancellation is not magic.
Your code and the APIs it uses need to respond appropriately.
Consider a search screen:
User Types:
"Mac"
↓
Search
User Types:
"MacBook"
↓
New SearchThe old request may no longer matter.
A modern design should allow the previous task to be cancelled.
The desired behavior is:
Search "Mac"
↓
Cancelled
Search "MacBook"
↓
ContinuesThis prevents stale results from racing with newer user intent.
Not every asynchronous operation produces one result.
Some produce a stream:
Event 1
Event 2
Event 3
Event 4
...Swift's asynchronous sequence model supports this pattern.
Conceptually:
AsyncSequence
↓
for await
↓
Process ValuesThis is useful for:
Network streams
Notifications
Database changes
Sensor data
Live updates
Event pipelines
The code remains structured while the data arrives over time.
A modern networking architecture might look like:
View
↓
@MainActor ViewModel
↓
Async Service
↓
URLSession
↓
APIThe ViewModel owns UI state.
The service handles network operations.
The API layer handles transport concerns.
This keeps concurrency boundaries clear.
For example:
UI State
↓
Request
↓
Network
↓
Decoded Value
↓
UI StateThe network operation does not need to know how the UI renders its result.
Actors are particularly useful for services that maintain mutable state.
Examples include:
Caches
Token managers
Connection state
In-memory stores
Synchronization managers
For example:
TokenStore Actor
│
├── Current Token
├── Refresh
└── ExpirationMultiple concurrent requests can ask for a token without each implementing their own synchronization logic.
This is a good example of actor isolation solving a real architectural problem.
Actors are powerful.
That does not mean every type should become an actor.
If you create:
Actor A
↓
Actor B
↓
Actor C
↓
Actor D
↓
Actor Eyou may introduce unnecessary asynchronous boundaries.
Every cross-actor interaction can involve suspension.
Instead, use actors where there is genuinely shared mutable state or an ownership boundary that benefits from isolation.
A good question is:
What state does this actor protect?
If the answer is "none," an actor may not be necessary.
Consider:
Background Task
↓
Main Actor
↓
Background Task
↓
Main ActorExcessive actor hopping can make code harder to reason about and can introduce unnecessary scheduling overhead.
Design clear ownership.
For example:
Background Service
↓
Result
↓
Main Actor
↓
UIOne clear transition is often easier to understand than repeatedly crossing isolation boundaries.
Concurrency bugs are difficult to reproduce manually.
Testing should therefore validate:
Cancellation
Concurrent access
Task lifetimes
Actor isolation
Failure propagation
Ordering assumptions
Race-sensitive behavior
A useful test structure is:
Test
↓
Start Task
↓
Perform Concurrent Work
↓
Await Result
↓
Verify StateAvoid tests that rely heavily on arbitrary delays such as:
sleep(1)Timing-based tests can become flaky.
Prefer explicit synchronization and awaited results.
An actor's important behavior usually revolves around its state transitions.
For example:
Cache
↓
Insert
↓
Read
↓
Invalidate
↓
ReadTests should verify that concurrent operations preserve the expected invariants.
The goal is not to test the actor keyword itself.
The goal is to test:
Does this isolated state remain correct under concurrent use?
Swift concurrency does not mean:
Every task gets its own thread.
Tasks are lightweight units of work scheduled by the runtime.
This distinction is important.
You can have:
Thousands of Tasks
↓
Managed Execution
↓
Available ThreadsThis allows applications to express large amounts of asynchronous work without manually creating huge numbers of threads.
But expensive CPU work still needs careful consideration.
Suppose an application processes:
Large Image
↓
Complex Transformationor:
Large Dataset
↓
CPU-Heavy CalculationMaking the function `async` does not automatically make the computation faster.
Concurrency can help when independent CPU work can be performed in parallel, but the application must still respect available compute resources.
The key distinction is:
Waiting / I/O
vs.
CPU ComputationConcurrency is particularly valuable for eliminating wasted waiting time.
A scalable application can use boundaries like:
SwiftUI / UIKit
│
▼
@MainActor
ViewModel
│
▼
Async Services
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Network Database Cache
│ │ │
│ │ Actor Isolation
│ │
└─────────────┼─────────────┘
▼
Sendable ModelsThe architecture makes ownership explicit:
UI state → Main Actor
Shared mutable state → Actors
Data crossing boundaries → Sendable values
Asynchronous operations → Structured tasks
Do not rewrite every callback at once.
Find areas with:
Nested completions
Manual dispatch queues
Locks
Shared mutable state
Race-condition bugs
Move simple callback APIs toward:
async
throwsThis creates a cleaner foundation.
Replace manually managed background work where appropriate.
Ask:
Who owns this state?
If multiple tasks need access, consider actor isolation.
Use the main actor intentionally around UI-facing state.
Especially for:
Search
Networking
Image processing
Live updates
Long-running operations
Make concurrency boundaries explicit.
Once actor isolation is working correctly, remove unnecessary locks and dispatching patterns.
Test real lifecycle and cancellation scenarios.
Watch:
UI responsiveness
CPU
Memory
Task cancellation
Network utilization
Latency
`async` makes asynchronous programming easier to express.
It does not automatically make CPU-heavy code faster.
UI state belongs there.
Not every service does.
Unstructured tasks without clear ownership can create lifecycle problems.
Actors should protect meaningful mutable state.
A cancelled task that continues expensive work is still wasting resources.
Crossing concurrency boundaries with mutable state can undermine safety.
Prefer immutable values where practical.
Timing assumptions create fragile tests.
Parallel work can increase contention or overload dependencies.
Actors are especially useful when:
State is mutable
Multiple tasks access it
The state has important invariants
Manual locking is becoming complex
The service has a clear ownership boundary
Good examples include:
Cache
Token Store
Connection Manager
Synchronization Service
In-Memory RepositoryThey are less compelling for immutable models or stateless utility functions.
Engineering teams adopting Swift concurrency should ask:
Where does mutable state live?
Which operations can safely run concurrently?
Who owns each task?
What happens when the user cancels the operation?
Which state belongs to the main actor?
Which data crosses concurrency boundaries?
Can the compiler verify our isolation assumptions?
Most importantly:
Are we using concurrency to simplify ownership and lifecycle—or simply adding more asynchronous code?
That distinction matters.
Swift concurrency is more than a modern replacement for completion handlers.
It is a different way of designing application behavior.
The older mental model often looks like:
Threads
↓
Queues
↓
Locks
↓
Callbacks
↓
Shared StateThe modern model becomes:
Structured Tasks
↓
async / await
↓
Actor Isolation
↓
Sendable Data
↓
Explicit CancellationThe strongest Swift applications use each piece for a specific purpose:
`async/await` makes asynchronous workflows readable.
Structured concurrency gives tasks clear lifetimes.
Task groups provide controlled parallelism.
Actors protect mutable shared state.
`@MainActor` isolates UI state.
`Sendable` makes cross-concurrency data movement safer.
Cancellation prevents obsolete work from consuming resources.
Async sequences make streaming data easier to model.
The biggest shift is conceptual:
Concurrency is fundamentally an ownership problem.
When developers know who owns state, who owns a task, when work should stop, and which data can safely cross isolation boundaries, concurrency becomes far easier to reason about.
You no longer need to ask:
Which queue should I dispatch this to?
as often.
Instead, you can ask:
Which concurrency domain owns this work and this state?
That is a much stronger architectural question.
And that is where Swift concurrency delivers its greatest value: not simply making asynchronous code shorter, but making its ownership, lifecycle, and safety much clearer.
When those principles are applied consistently, applications can perform network and background work without freezing the UI, coordinate concurrent operations without fragile locking, and protect shared state without turning the codebase into a maze of dispatch queues.
Mastering Swift concurrency ultimately means learning to design for safe parallel work from the beginning—so concurrency becomes a property of the architecture rather than a source of mysterious bugs discovered after release.
We build custom software, mobile apps, and web platforms for startups and enterprises.



Their team became an extension of ours — within months they'd rebuilt our entire product experience from the ground up.
