You open a photo gallery, start loading a few large images, and watch the spinner stop spinning. The networking code uses await. The loading method is async. There is even a Task around the call.
So why does scrolling still freeze?
Usually, the expensive part is somewhere after the download: decoding, resizing, filtering, or preparing the results for display. If that work executes on the main actor, adding more asynchronous syntax does not make it cheaper or move it elsewhere.
Let’s follow one gallery thumbnail through the pipeline and make the execution boundary explicit. The goal is a screen that stays responsive while work is happening, and stops doing unnecessary work when you leave.
1. An await is a possible pause, not a background switch
An async function can suspend. An await marks a potential suspension point; it does not guarantee that execution actually suspends there. Between suspension points, ordinary synchronous instructions keep executing. Swift’s concurrency language guide explains this distinction.
Consider this deliberately slow version. It compiles alongside the thumbnail worker introduced below:
import Foundation
@MainActor
func loadThumbnailBadly(from url: URL) async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: url)
return try ThumbnailWorker.makeJPEG(from: data, maxPixelSize: 360)
}
The request can suspend while the network does its work. Once the response arrives, this function resumes on MainActor. Its synchronous call to makeJPEG occupies that actor until the transformation finishes.
Networking being asynchronous says nothing about the processing that follows it. The same mistake appears with JSON decoding, sorting a large result set, and rebuilding an attributed string after an API response.
It is also possible to block the main thread without using an actor at all. Here, though, the isolation annotation tells us exactly where to start investigating.
2. Task inherits the context you already have
This wrapper does not fix the problem:
@MainActor
func startBadLoad(from url: URL) {
Task {
do {
_ = try await loadThumbnailBadly(from: url)
} catch {
print("Thumbnail failed: \(error)")
}
}
}
The task is created in a main-actor-isolated function, so its closure inherits that actor context. Lowering its priority would not change its isolation. Also, Task {} creates an unstructured task; this function does not keep a handle to cancel it. Apple’s Task documentation describes these lifecycle and context rules.
Keep @MainActor on state the UI reads and changes. The fix is to separate the expensive transformation from that state, rather than removing isolation until the warnings disappear. If those boundaries are still unfamiliar, our guide to concurrency-safe value and reference types in Swift 6 explains the data-safety side.
3. Make the CPU boundary explicit with @concurrent
The examples below target iOS 17 or later, Swift 6 language mode, and a Swift 6.2-or-later compiler. Use MainActor as the target’s Default Actor Isolation and enable NonisolatedNonsendingByDefault; it is also included in Approachable Concurrency settings. Explicit annotations make the worker’s boundary visible even in a target with different defaults.
With that feature enabled, a nonisolated async function stays on the caller’s actor. Under the earlier behavior, it switches to the generic executor. @concurrent, introduced in Swift 6.2, explicitly requests leaving the caller’s actor. These are compiler/settings distinctions, not something determined by the deployment target alone. Swift Evolution SE-0461 specifies the behavior.
Here is the worker. It accepts encoded image bytes, downsamples with ImageIO, and returns encoded thumbnail bytes:
import Foundation
import ImageIO
import UniformTypeIdentifiers
nonisolated enum ThumbnailError: Error {
case invalidImage
case encodingFailed
case invalidResponse
}
nonisolated enum ThumbnailWorker {
@concurrent
static func jpeg(from data: Data, maxPixelSize: Int) async throws -> Data {
try Task.checkCancellation()
let result = try makeJPEG(from: data, maxPixelSize: maxPixelSize)
try Task.checkCancellation()
return result
}
static func makeJPEG(from data: Data, maxPixelSize: Int) throws -> Data {
guard maxPixelSize > 0,
let source = CGImageSourceCreateWithData(
data as CFData,
[kCGImageSourceShouldCache: false] as CFDictionary
) else {
throw ThumbnailError.invalidImage
}
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
kCGImageSourceShouldCacheImmediately: true
]
guard let image = CGImageSourceCreateThumbnailAtIndex(
source, 0, options as CFDictionary
) else {
throw ThumbnailError.invalidImage
}
try Task.checkCancellation()
let output = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(
output, UTType.jpeg.identifier as CFString, 1, nil
) else {
throw ThumbnailError.encodingFailed
}
CGImageDestinationAddImage(destination, image, nil)
guard CGImageDestinationFinalize(destination) else {
throw ThumbnailError.encodingFailed
}
return output as Data
}
}
The synchronous helper does no scheduling. Calling it directly from the main actor, as our bad example does, still blocks that actor. Production callers use the asynchronous jpeg entry point.
ImageIO’s thumbnail creation API lets us request a maximum pixel dimension and apply the source’s orientation transform. This is preferable here to fully decoding a large image just to draw a small version of it.
Only Data and an integer cross the boundary. The mutable encoding buffer and Core Graphics objects stay inside the worker. There is no captured view model and no custom @unchecked Sendable conformance for a UIKit image.
Returning JPEG is an intentional simplification: it costs an encode plus a later small decode and does not preserve alpha. For a photo gallery that is a reasonable teaching example, not a universal image-cache format. Measure those costs before adopting it for a high-throughput pipeline.
4. Connect the worker to the view’s lifetime
Add this model and view to the same iOS target. Pass GalleryThumbnail a valid HTTPS photo URL; no application entry point is required to embed it in an existing screen.
import SwiftUI
import Observation
import UIKit
@MainActor
@Observable
final class ThumbnailModel {
var image: UIImage?
var errorMessage: String?
private var generation = UUID()
func load(_ url: URL) async {
let request = UUID()
generation = request
image = nil
errorMessage = nil
do {
try Task.checkCancellation()
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
throw ThumbnailError.invalidResponse
}
let bytes = try await ThumbnailWorker.jpeg(
from: data, maxPixelSize: 360
)
try Task.checkCancellation()
guard generation == request else { return }
guard let thumbnail = UIImage(data: bytes) else {
throw ThumbnailError.invalidImage
}
image = thumbnail
} catch {
guard generation == request, !Task.isCancelled else { return }
errorMessage = "Couldn't load this photo. Please try again."
}
}
}
@MainActor
struct GalleryThumbnail: View {
let url: URL
@State private var model = ThumbnailModel()
@State private var retry = 0
private struct RequestID: Equatable {
let url: URL
let retry: Int
}
var body: some View {
Group {
if let image = model.image {
Image(uiImage: image)
.resizable()
.scaledToFit()
} else if let message = model.errorMessage {
VStack {
Text(message)
Button("Retry") { retry += 1 }
}
} else {
ProgressView()
}
}
.frame(width: 120, height: 120)
.task(id: RequestID(url: url, retry: retry)) {
await model.load(url)
}
}
}
This uses Observation for SwiftUI model state. The image object is created and retained on the main actor, while the large source-image transformation happens in the worker.
The same actor boundary matters when this view is hosted by UIKit; the guide to embedding SwiftUI with UIHostingController and UIHostingConfiguration shows how to keep UIKit-owned navigation and reusable cells from taking ownership of the processing work.
SwiftUI associates the task with the view lifecycle and cancels/restarts it when its ID changes. Crucially, load does not launch another Task inside that task. The asynchronous call to the worker continues the same task, so its cancellation checks observe the same cancellation state. See Apple’s task modifier documentation.
The generation token handles overlapping loads: an older request cannot replace a newer result or error. Actor isolation prevents simultaneous access, but a method can be reentered while suspended. That is why main-actor isolation alone does not guarantee that the most recent request wins.
The 360-pixel cap suits a 120-point tile at 3× scale. In a reusable component, calculate the requested size from the layout and display scale. Avoid asking every tile for the full-resolution original if the server already provides thumbnails.
5. Cancellation is a checkpoint, not an emergency brake
Leaving the screen can cancel the task, but cancellation does not interrupt arbitrary synchronous code. An ImageIO call already processing a photograph must return before our next check runs. The checks before processing, before encoding, and before presentation prevent additional unnecessary work and stale updates.
For a longer custom pixel-processing loop, check periodically inside the loop. Choose a useful chunk size; checking every pixel can add overhead, while checking only after thousands of images is too late. Swift’s cooperative cancellation model requires the work to participate.
Keep memory in the discussion too. Off-main processing can still retain the downloaded bytes, decoded pixels, and output together. A gallery launching hundreds of transformations can run out of memory without ever blocking the main actor for one long interval. Use bounded concurrency and a cache budget, and inspect task lifetimes and memory ownership when a dismissed screen leaves work behind.
6. Why not just use Task.detached?
Task.detached can be useful for an independently managed operation. But it creates another unstructured task and does not inherit the surrounding actor context, task-local values, or initial priority. Cancellation of the caller does not automatically cancel it. Awaiting its result does not turn it into a structured child. SE-0304 details these distinctions.
For this thumbnail request, that independence adds lifecycle work we do not need. @concurrent gives the processing function an explicit execution boundary while keeping the current task.
If a legacy integration truly needs a detached task, retain its handle, deliberately choose a priority, and forward cancellation using a cancellation handler. Its body must still check for cancellation. Also audit the functions it calls: detaching a closure does not override a called function’s @MainActor annotation.
Neither approach gives CPU work a private unlimited thread budget. Keep transformations bounded and avoid blocking waits such as semaphores inside cooperative tasks.
7. Prove the improvement with Instruments
Before changing the pipeline, record a repeatable interaction on a physical device: open the same gallery, scroll while thumbnails arrive, then navigate away. Repeat after the change using the same images and comparable cache conditions.
Use Time Profiler to inspect the main-thread stack during the freeze. Look for ImageIO, image decoding, resizing, or your transformation helper. Use the Hangs instrument for sustained unresponsiveness and animation-hitch analysis for dropped frames; a visible scrolling hitch need not qualify as a long hang. Apple’s responsiveness guide explains the distinction and relevant tools.
Once the fix ships, use MetricKit to monitor production hangs and launch regressions that a controlled Instruments trace may not reproduce.
After the change, verify that the expensive source transformation no longer occupies the main actor. Then inspect what remains. UIImage(data:) and rendering the encoded thumbnail can still involve decoding; our worker has reduced the image size, not eliminated every presentation cost. If that remains significant, profile an image-preparation or caching strategy separately.
If Allocations instead points to repeated value-buffer copies, profile Swift ownership boundaries with borrowing and consuming before changing the concurrency architecture.
Check navigation away as well: do later transformations stop starting, and does memory settle? Track both interaction smoothness and time until the useful image appears. A lower main-thread cost accompanied by excessive CPU use or slower loading is a tradeoff to investigate, not an automatic win.
The examples were type-checked using Apple Swift 6.3.3; the language features described require Swift 6.2 or later. Compiler validation is not a device performance measurement, and no benchmark numbers are claimed here.
8. Conclusion
When asynchronous code freezes the UI, follow the expensive work rather than counting await keywords. In this gallery, the download was already asynchronous. The source-image transformation needed an explicit boundary, transferable inputs and outputs, and cancellation tied to the view.
Once those pieces are visible, reviewing the code becomes easier: UI state belongs to the main actor, processing has a named entry point, and a disappearing screen can stop requesting results it no longer needs.
Key Takeaways
asyncpermits suspension; it does not promise background execution.- A
Taskcreated in main-actor-isolated code inherits that context. - Check concurrency settings before assuming what
nonisolated asyncmeans. - Use
@concurrentfor an explicit off-actor processing boundary on Swift 6.2 or later. - Keep cancellation cooperative, guard against stale results, and measure the actual interaction on a device.