Agency

Mastering Swift Concurrency and Actors: Building Safe, Responsive, and Scalable Apple Apps

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

LAST UPDATED: August 31, 2025
10 min read
Mastering Swift Concurrency and Actors: Building Safe, Responsive, and Scalable Apple Apps

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.

Why Swift Concurrency Matters

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
        ↓
      UI

At 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 Cancellation

Swift concurrency provides a cleaner model:

Request
  ↓
await
  ↓
Request
  ↓
await
  ↓
Update UI

The 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.

From Completion Handlers to Structured Concurrency

Consider the conceptual difference.

Older callback style

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
      ↓
Caller

Understanding async and await

An `async` function can suspend while waiting for asynchronous work.

For example:

func loadProfile() async throws -> Profile

The 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.

Tasks and Structured Concurrency

A task represents a unit of asynchronous work.

Conceptually:

Task
 │
 ├── Operation A
 ├── await
 ├── Operation B
 └── Result

Structured 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

Unstructured Tasks Still Have a Place

Sometimes you genuinely need work that is not naturally nested inside the current operation.

For example:

User Action
   ↓
Start Background Work

Swift 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
 └── Task

Ask:

Who owns this task, and when should it stop?

If there is no clear answer, the concurrency design probably needs another look.

Task Groups for Parallel Work

One of the strongest advantages of structured concurrency is the ability to perform independent operations concurrently.

Suppose a dashboard needs:

User Profile
Orders
Recommendations
Notifications

If these operations do not depend on each other, executing them sequentially wastes time.

Instead:

             Dashboard
                 │
       ┌─────────┼─────────┐
       ▼         ▼         ▼
     Profile   Orders   Recommendations
       │         │         │
       └─────────┼─────────┘
                 ▼
              Render

Task 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.

Parallelism Is Not Free

Concurrency should not be added simply because it is available.

Consider:

10,000 Tasks
     ↓
Database

If 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 and Protected Mutable State

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 State

the state belongs to the actor:

Actor
 ├── State
 ├── Methods
 └── Invariants

Access to that state follows actor isolation rules.

Actors Are More Than "Thread-Safe Classes"

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.

A Good Actor Boundary

Consider a cache:

ImageCache
 ├── Cached Images
 ├── Read
 ├── Insert
 └── Remove

Multiple tasks may need to access it.

Instead of:

Global Dictionary
      ↓
Manual Locking

an actor can own the state:

ImageCache Actor
      ↓
Protected Dictionary

Now the ownership boundary is explicit.

This is often much easier to maintain than scattered locking logic.

The Main Actor and UI Safety

Apple platform applications have one especially important actor:

@MainActor

UI-related state generally belongs on the main actor.

Conceptually:

Background Work
      ↓
Fetch / Process
      ↓
Main Actor
      ↓
Update UI

For example:

Load Data
   ↓
await
   ↓
Update View State

The main actor ensures UI-related mutations happen within the appropriate isolation domain.

Don't Put Everything on the Main Actor

A common mistake is:

Entire Application
       ↓
@MainActor

That can defeat the purpose of concurrency.

If CPU-heavy work runs on the main actor:

Heavy Processing
      ↓
Main Actor
      ↓
UI Responsiveness Drops

Instead, isolate UI state while allowing appropriate background work to run elsewhere.

For example:

@MainActor
View Model
     ↓
Async Service
     ↓
Background Work

The UI remains isolated without making the entire system UI-bound.

Sendable and Safe Data Transfer

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 B

A 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.

Value Types Are Powerful Here

Swift's value semantics make many concurrency designs easier.

For example:

struct Product

can 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.

Cancellation Is Part of Correctness

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 Stops

The 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 Search

The 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"
    ↓
Continues

This prevents stale results from racing with newer user intent.

Async Sequences and Streaming Data

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 Values

This is useful for:

Network streams

Notifications

Database changes

Sensor data

Live updates

Event pipelines

The code remains structured while the data arrives over time.

Concurrency and Networking

A modern networking architecture might look like:

View
 ↓
@MainActor ViewModel
 ↓
Async Service
 ↓
URLSession
 ↓
API

The 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 State

The network operation does not need to know how the UI renders its result.

Actors for Caching and Shared Services

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
   └── Expiration

Multiple 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.

Avoid the "Actor Everywhere" Architecture

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 E

you 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.

Avoid Unnecessary Main-Actor Hops

Consider:

Background Task
   ↓
Main Actor
   ↓
Background Task
   ↓
Main Actor

Excessive 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
        ↓
UI

One clear transition is often easier to understand than repeatedly crossing isolation boundaries.

Testing Swift Concurrency

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 State

Avoid tests that rely heavily on arbitrary delays such as:

sleep(1)

Timing-based tests can become flaky.

Prefer explicit synchronization and awaited results.

Testing Actors

An actor's important behavior usually revolves around its state transitions.

For example:

Cache
 ↓
Insert
 ↓
Read
 ↓
Invalidate
 ↓
Read

Tests 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?

Performance and Threading

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 Threads

This allows applications to express large amounts of asynchronous work without manually creating huge numbers of threads.

But expensive CPU work still needs careful consideration.

CPU-Bound Work Needs Different Thinking

Suppose an application processes:

Large Image
   ↓
Complex Transformation

or:

Large Dataset
   ↓
CPU-Heavy Calculation

Making 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 Computation

Concurrency is particularly valuable for eliminating wasted waiting time.

A Modern Swift Concurrency Architecture

A scalable application can use boundaries like:

                       SwiftUI / UIKit
                              │
                              ▼
                         @MainActor
                         ViewModel
                              │
                              ▼
                       Async Services
                              │
                ┌─────────────┼─────────────┐
                ▼             ▼             ▼
             Network       Database       Cache
                │             │             │
                │             │        Actor Isolation
                │             │
                └─────────────┼─────────────┘
                              ▼
                       Sendable Models

The architecture makes ownership explicit:

UI state → Main Actor

Shared mutable state → Actors

Data crossing boundaries → Sendable values

Asynchronous operations → Structured tasks

How to Modernize an Existing Codebase

Do not rewrite every callback at once.

Step 1 — Identify Callback Hotspots

Find areas with:

Nested completions

Manual dispatch queues

Locks

Shared mutable state

Race-condition bugs

Step 2 — Convert Clear Async APIs

Move simple callback APIs toward:

async
throws

This creates a cleaner foundation.

Step 3 — Introduce Structured Tasks

Replace manually managed background work where appropriate.

Step 4 — Identify Shared Mutable State

Ask:

Who owns this state?

If multiple tasks need access, consider actor isolation.

Step 5 — Isolate UI State

Use the main actor intentionally around UI-facing state.

Step 6 — Add Cancellation

Especially for:

Search

Networking

Image processing

Live updates

Long-running operations

Step 7 — Adopt Sendable Where Useful

Make concurrency boundaries explicit.

Step 8 — Remove Legacy Synchronization

Once actor isolation is working correctly, remove unnecessary locks and dispatching patterns.

Step 9 — Test Concurrency Behavior

Test real lifecycle and cancellation scenarios.

Step 10 — Measure

Watch:

UI responsiveness

CPU

Memory

Task cancellation

Network utilization

Latency

Common Swift Concurrency Mistakes

Treating async as a Performance Feature

`async` makes asynchronous programming easier to express.

It does not automatically make CPU-heavy code faster.

Putting Everything on @MainActor

UI state belongs there.

Not every service does.

Creating Tasks Everywhere

Unstructured tasks without clear ownership can create lifecycle problems.

Using Actors Without a State Ownership Problem

Actors should protect meaningful mutable state.

Ignoring Cancellation

A cancelled task that continues expensive work is still wasting resources.

Sharing Mutable Reference Types

Crossing concurrency boundaries with mutable state can undermine safety.

Prefer immutable values where practical.

Using Sleeps to Synchronize Tests

Timing assumptions create fragile tests.

Assuming Parallel Means Faster

Parallel work can increase contention or overload dependencies.

When Actors Are the Right Tool

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 Repository

They are less compelling for immutable models or stateless utility functions.

Making the Call

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.

Final Takeaway

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 State

The modern model becomes:

Structured Tasks
      ↓
async / await
      ↓
Actor Isolation
      ↓
Sendable Data
      ↓
Explicit Cancellation

The 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.

Frequently Asked Questions

Structured concurrency ensures that tasks have a well-defined lifetime, owner, and cancellation behavior, matching the scope of the code that creates them. Unstructured tasks, while useful for fire-and-forget background work, lack this automatic lifecycle management and can easily lead to orphaned work if not handled carefully.
While UI state must be updated on the main actor, placing your entire application or heavy processing logic on the main actor defeats the purpose of concurrency. It's best to isolate only the necessary UI-facing properties with @MainActor and keep business logic and background tasks running concurrently on other executors.
Actors provide a language-level concurrency boundary around mutable state. Unlike manual locking where developers must remember to acquire and release locks, the Swift compiler enforces actor isolation rules, ensuring that only one task can access or modify the actor's protected state at any given time.

Need a product built?

We build custom software, mobile apps, and web platforms for startups and enterprises.

Alejandro D.
Vatsalya R.Backend Developer
Gustavo A.
Ganeshan S.Sr. Software Engineer
Fiorella G.
Uptal JoshiSr. Data Scientist

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

BitForge
Sr. ArchitectBitForge
Read Case Study