Highlight
A new App project in Xcode 26 turns on Main Actor mode by default. All code is implicitly
@MainActor. When you need to run something in the background, mark it@concurrent.
Core content
Most developers have hit the same trap on their first network request: call URLSession.shared.data(from:) synchronously on the main thread, and the UI freezes for a few seconds. The app looks dead. The main thread has to handle touch events and redraw the UI, so once network IO holds it, nothing else moves. The Swift team’s answer this time is not another GCD API. It moves “which thread does this run on” into the language as a property the compiler can check.
A new App project in iOS 19 / Xcode 26 turns on Main Actor mode by default. Code you write without any annotation is implicitly @MainActor. The compiler knows it can only run on the main thread, so it lets you read global variables and singletons freely. When some computation, say image decoding, starts dropping frames, you mark that function @concurrent. The compiler then tells you exactly which main-thread-only state it touches and forces you to wrap that state in a value type or an actor before passing it across. The migration path is gradual: start single-threaded, use async/await for high-latency calls, use @concurrent to offload CPU-bound work, and finally use actor to move the data itself off the main thread.
Details
The whole talk is built around an image-loading example. The first version is synchronous and blocking:
final class ImageModel {
var imageCache: [URL: Image] = [:]
let view = View()
func fetchAndDisplayImage(url: URL) throws {
let data = try Data(contentsOf: url)
let image = decodeImage(data)
view.displayImage(image)
}
}
Key points:
fetchAndDisplayImageis a synchronous function. The whole flow runs on the main thread in one shot.- Reading a small local file is fine. Swap in a network URL, and the main thread blocks on network IO and the UI freezes.
Step one is to make it async so network IO no longer blocks the main thread (06:10):
final class ImageModel {
var imageCache: [URL: Image] = [:]
let view = View()
func fetchAndDisplayImage(url: URL) async throws {
let (data, _) = try await URLSession.shared.data(from: url)
let image = decodeImage(data)
view.displayImage(image)
}
}
Key points:
- The signature gains
async. The call toURLSessionusesawait. awaitmarks a suspension point. The function gives up the main thread there and resumes when data comes back.- The function still runs on the main thread, but while it waits for the network the main thread can handle touches and draw other UI.
URLSessiondoes the actual IO on a background thread. The application code itself introduces no concurrency.
An async function has to start inside a task. On a button tap you write (07:31):
func onTapEvent() {
Task {
do {
try await fetchAndDisplayImage(url: url)
} catch let error {
displayError(error)
}
}
}
Key points:
Task { ... }creates a new task that wraps the whole fetch + display flow.- The task runs in order from start to finish. Multiple tasks interleave on the main thread.
- The error is caught inside the task, so it does not get swallowed.
Only when the app actually hits a main-thread stall, say decodeImage is too slow on large images, do you move that part to the background. Use @concurrent (11:11):
final class ImageModel {
var imageCache: [URL: Image] = [:]
let view = View()
func fetchAndDisplayImage(url: URL) async throws {
let (data, _) = try await URLSession.shared.data(from: url)
let image = await decodeImage(data, at: url)
view.displayImage(image)
}
@concurrent
func decodeImage(_ data: Data, at url: URL) async -> Image {
Image()
}
}
Key points:
@concurrenttells Swift this function always runs in the background.- The caller uses
await. While decoding runs, the main thread keeps doing other work. decodeImageis no longer in the main actor’s isolation domain, so the compiler refuses to let it touchimageCachedirectly. That is the point: it forces you to handle cross-thread shared data explicitly.
For data races on shared mutable reference types, the talk shows the actor approach (25:10):
actor NetworkManager {
var openConnections: [URL: Connection] = [:]
func openConnection(for url: URL) async -> Connection {
if let connection = openConnections[url] {
return connection
}
let connection = Connection()
openConnections[url] = connection
return connection
}
func closeConnection(_ connection: Connection, for url: URL) async {
openConnections.removeValue(forKey: url)
}
}
Key points:
- Change
classtoactor. The mutable state inopenConnectionsis now isolated by the actor. - External callers of
openConnection/closeConnectionmustawait. The actor guarantees only one call runs inside it at a time. - The main thread and background threads can all use the same
NetworkManagersafely, without manual locking.
Value types take a different path. struct and enum are Sendable by default. Their copy semantics avoid sharing automatically when you pass them across tasks (16:56). Reach for value types first; only when shared mutable state is unavoidable do you bring in an actor. That is the order Apple recommends.
Takeaways
-
What to do: confirm Main Actor default mode is on for new iOS 19 projects
- Why it matters: the new Xcode 26 template enables it by default. The compiler adds
@MainActorto your UI module code automatically, which removes a pile of explicit annotations. Reading UI singletons and global state no longer trips data race warnings. - How to start: search “Default Actor Isolation” in Build Settings and confirm it is
MainActor. For older projects, switch UI-related modules first and leave business logic modules alone for now.
- Why it matters: the new Xcode 26 template enables it by default. The compiler adds
-
What to do: replace every synchronous
URLSession.shared.data(...)call withasync/await- Why it matters: synchronous network IO on the main thread almost always stalls the UI. The
asyncversion does not require you to introduce concurrency. A singleawaitturns a stall into a suspension point. - How to start: grep for
Data(contentsOf:)and any synchronousURLSessionuse. Make the callerasyncand start it from a button callback withTask { }.
- Why it matters: synchronous network IO on the main thread almost always stalls the UI. The
-
What to do: use Instruments to find the calls that actually burn main-thread time, then add
@concurrent- Why it matters: the talk says it plainly: many apps need no concurrency at all. Moving every slow call to the background just complicates the code and adds new data races. Profile first.
- How to start: run Time Profiler in Instruments and find the function with the highest main-thread share. If it really is CPU-bound (image decoding, parsing a large JSON), mark it
@concurrentand let the compiler tell you which isolated state it touches.
-
What to do: refactor cross-thread shared mutable reference types into actors
- Why it matters: a singleton
classaccessed from multiple threads needs manual locking, and that is easy to get wrong. Anactorbuilds the synchronization into the language. Callers mustawait, and the compiler blocks every data race for you. - How to start: list the manager and cache classes that several threads touch and convert them to
actorone by one. Every external call mustawait, which may force functions up the call chain to becomeasync.
- Why it matters: a singleton
-
What to do: pass cross-task data with value types and
Sendable- Why it matters: a
structisSendableby default. Copy semantics avoid sharing without effort, and that is much simpler than an actor. Apple lists “prefer value types” as the first principle. - How to start: review the data you pass between tasks. Use
structwhenever you can instead ofclass. If a reference type has to travel, make sure it is immutable and mark itSendable; otherwise convert it to a value-type snapshot.
- Why it matters: a
Related sessions
- Code-along: Cook up a rich text experience in SwiftUI with AttributedString — build a rich text editor in SwiftUI and pair it with Swift concurrency for data updates
- Explore Swift and Java interoperability — mix Swift and Java in one codebase and handle concurrency boundaries across languages
- Improve memory usage and performance with Swift — Swift memory management and performance tips, a good follow-up after offloading work with
@concurrent - Optimize SwiftUI performance with Instruments — use the new SwiftUI Instrument to track UI stalls and decide whether you need to introduce concurrency
Comments
GitHub Issues · utterances