Highlight
Move image processing off the main thread with a
nonisolatedtype and a@concurrentmethod, then run sticker extraction and color analysis in parallel usingasync letto wipe out a Severe Hang of more than 10 seconds.
Core content
Sima is building an app that turns photos into a sticker album. Everything ran fine until she added the “sticker cutout + dominant color” step. The Time Profiler in Instruments delivered a clear verdict: Severe Hang, with the heaviest stack frame sitting on PhotoProcessor and the main thread blocked for more than 10 seconds (11:23). Scrolling froze and taps stopped working. The problem was not the algorithm; it was that this work had no business running on the main thread.
Xcode 26 turns on main actor by default, which hides the issue further: every type is bound to @MainActor unless you say otherwise, so it gets queued onto the main thread. The session walks down a clear path. First use async/await so the UI can wait for asynchronous loading. Then use nonisolated + @concurrent to push expensive work to the background. Then use async let to run two independent tasks in parallel. Finally use withTaskGroup for a variable number of concurrent jobs. Each step starts with Instruments showing a real performance problem, and reaches for just enough concurrency to fix it, without piling on extra complexity ahead of time.
Details
Step 1: Trigger asynchronous loading with task (06:29)
PhotosPickerItem provides loadTransferable, which is already asynchronous. Mark loadPhoto as async and add await at the call site:
func loadPhoto(_ item: SelectedPhoto) async {
var data: Data? = try? await item.loadTransferable(type: Data.self)
if let cachedData = getCachedData(for: item.id) { data = cachedData }
guard let data else { return }
processedPhotos[item.id] = Image(data: data)
cacheData(item.id, data)
}
Key points:
- An
asyncfunction may contain suspension points;awaitmarks one specific suspension point. - While
loadTransferableis suspended, the main thread is free to handle UI events while the framework runs the load in the background. - When
loadPhotoresumes, it is back on the main thread, so updating theprocessedPhotosdictionary is safe.
In SwiftUI, kick off this async function with the task modifier:
StickerPlaceholder()
.task {
await viewModel.loadPhoto(selectedPhoto)
}
Key points:
taskstarts when the view appears and is cancelled automatically when the view disappears.- Combined with LazyHStack, only the placeholders visible on screen trigger a load, so no work is wasted.
Step 2: Move PhotoProcessor off the main thread (14:13)
After adding sticker cutout, the app stutters badly. Instruments shows the main thread blocked for more than 10 seconds. The cause: under main actor by default, PhotoProcessor is bound to the main thread:
nonisolated struct PhotoProcessor {
let colorExtractor = ColorExtractor()
@concurrent
func process(data: Data) async -> ProcessedPhoto? {
let sticker = extractSticker(from: data)
let colors = extractColors(from: data)
guard let sticker = sticker, let colors = colors else { return nil }
return ProcessedPhoto(sticker: sticker, colorScheme: colors)
}
private func extractColors(from data: Data) -> PhotoColorScheme? {
// ...
}
private func extractSticker(from data: Data) -> Image? {
// ...
}
}
Key points:
nonisolatedon the struct lifts the whole type off MainActor; every method and property becomes nonisolated automatically (a Swift 6.1 feature).@concurrentis a new attribute that tells Swift this method always runs on a background thread and never inherits the caller’s actor.asyncforces the caller to useawait— that “thread switch” becomes visible at the type level.- The call site becomes
await PhotoProcessor().process(data: data), and the main thread keeps handling scroll gestures while it waits.
Step 3: Run sticker extraction and color analysis in parallel (20:55)
Cutout and color analysis are independent, so they can run at the same time with async let:
nonisolated struct PhotoProcessor {
@concurrent
func process(data: Data) async -> ProcessedPhoto? {
async let sticker = extractSticker(from: data)
async let colors = extractColors(from: data)
guard let sticker = await sticker, let colors = await colors else { return nil }
return ProcessedPhoto(sticker: sticker, colorScheme: colors)
}
private func extractColors(from data: Data) -> PhotoColorScheme? {
let colorExtractor = ColorExtractor()
return colorExtractor.extractColors(from: data)
}
private func extractSticker(from data: Data) -> Image? {
// ...
}
}
Key points:
async letstarts a child task immediately, sending the sticker and colors computations to different threads to run concurrently.await stickerandawait colorsare where the code actually waits for results; both tasks finish around the same time, so the total time is close to the longer of the two.- ColorExtractor moves from a stored property to a local variable inside
extractColors— each concurrent task creates its own instance, avoiding a data race on the shared mutable pixel buffer.
Step 4: Handle data races in SwiftUI closures (24:20)
The visualEffect closure is @Sendable, and SwiftUI calls it on a background thread. Reading viewModel.selection (a @MainActor state) directly fails to compile. The fix is to copy the value into the capture list:
.visualEffect { [selection = viewModel.selection] content, proxy in
let frame = proxy.frame(in: .scrollView(axis: .horizontal))
let distance = min(0, frame.minX)
let isLast = selectedPhoto.id == selection.last?.id
return content
.hueRotation(.degrees(frame.origin.x / 10))
.scaleEffect(1 + distance / 700)
.offset(x: isLast ? 0 : -distance / 1.25)
.brightness(-distance / 400)
.blur(radius: isLast ? 0 : -distance / 50)
.opacity(isLast ? 1.0 : min(1.0, 1.0 - (-distance / 400)))
}
Key points:
[selection = viewModel.selection]takes the snapshot on the main thread; the closure body uses the copiedselectionand never touchesselfagain.- Value types (arrays, IDs) are cheap to copy and Sendable by nature, so reading them across threads is safe.
Step 5: Process the whole album with TaskGroup (29:00)
When the grid view appears, every photo has to be processed at once, and the count is only known at runtime. Use TaskGroup:
func processAllPhotos() async {
await withTaskGroup { group in
for item in selection {
guard processedPhotos[item.id] == nil else { continue }
group.addTask {
let data = await self.getData(for: item)
let photo = await PhotoProcessor().process(data: data)
return photo.map { ProcessedPhotoResult(id: item.id, processedPhoto: $0) }
}
}
for await result in group {
if let result {
processedPhotos[result.id] = result.processedPhoto
}
}
}
}
Key points:
withTaskGroupfits cases where the task count is not fixed at compile time;async letcannot.group.addTaskadds child tasks dynamically inside the loop; TaskGroup conforms to AsyncSequence.for await result in groupreturns results in completion order, not submission order — whichever finishes first writes into the dictionary first.- The whole group waits for every child task to finish when
await withTaskGroupreturns — structured concurrency keeps tasks from leaking.
Takeaways
1. Profile first, then add concurrency
Why it matters: optimizing without measurements is guesswork. Throughout the session, Sima keeps using the Time Profiler in Instruments to confirm the main thread is blocked before deciding to switch threads.
How to start: in Xcode, choose Product → Profile, run Time Profiler, and look for your own code in the heaviest stack trace stuck on the main thread. Act when you see Severe Hang.
2. Use nonisolated + @concurrent to say “this type is not part of the main thread”
Why it matters: main actor by default makes things run on the main thread by default, which is sensible for UI code and fatal for compute code. Putting nonisolated on a type lifts the whole type off MainActor; @concurrent on an async method forces it to a background thread. This reads more clearly than wrapping every call site in Task { @concurrent in ... }.
How to start: pick out the pure-compute types in your project (image processing, decoding, signing, and so on), mark them as nonisolated struct or nonisolated class, and mark their entry methods @concurrent async.
3. Solve data races by not sharing
Why it matters: ColorExtractor started as a stored property on PhotoProcessor, so concurrent calls collided. Moving it to a local variable in the function gives each task its own instance, and the problem is gone. That is cheaper than adding a lock or an actor.
How to start: when you hit a Sendable error, ask first — does this mutable state really need to be shared? If you can build a fresh one on every call, do not make it a property.
4. Copy values through capture lists in SwiftUI’s @Sendable closures
Why it matters: closures like visualEffect and onGeometryChange get called on background threads. Reading self.someMainActorProperty inside them fails to compile; taking a value-type snapshot in the capture list fixes it.
How to start: when you see “Sending main actor-isolated value to nonisolated context”, check whether the closure is @Sendable, then copy with [x = self.x].
5. Use TaskGroup for dynamic counts, async let for fixed counts
Why it matters: async let is the simplest form of structured concurrency, but it only opens a fixed number of branches. For “every photo in the album” cases where the count is known at runtime, you need withTaskGroup plus for await to consume results in completion order.
How to start: when you write a concurrent batch job, count the tasks first — fixed 2–3 branches, use async let; addTask in a loop, use TaskGroup; once everything is queued, do not forget for await result in group to collect the results.
Related sessions
- What’s new in Swift — Swift 6.2 language updates, including the
@concurrentand nonisolated type modifiers used in this session. - Code-along: Explore localization with Xcode — a sibling code-along that walks through Xcode’s localization flow with a sample app.
- Meet Containerization — an open-source container project written in Swift, showing Swift in system-level concurrent engineering.
- Get started with Game Center — a Game Center primer that highlights the async API patterns common when a SwiftUI app talks to system services.
Comments
GitHub Issues · utterances