Fix: Swift async/await Not Working — Task Not Running, Actor Isolation Error, or MainActor Crash
Part of: Mobile Development Errors
Quick Answer
How to fix Swift concurrency issues — async/await in non-async context, Task creation, actor isolation violations, MainActor UI updates, structured concurrency, and Sendable errors.
The Problem
Swift async/await code compiles but never runs, or crashes at runtime:
func loadData() {
let result = await fetchUsers() // Error: 'async' call in a function that is not marked 'async'
}Or actor isolation causes unexpected compile errors:
actor UserCache {
var users: [User] = []
}
let cache = UserCache()
cache.users.append(user) // Error: Expression is 'async' but is not marked with 'await'Or UI updates from async code crash on the main thread:
Task {
let data = await api.fetchData()
label.text = data.title // Warning: Publishing changes from background thread
}Or structured concurrency with async let doesn’t behave as expected:
async let users = fetchUsers()
async let posts = fetchPosts()
// Both start — but how do you handle errors from either one?Why This Happens
Swift’s concurrency model enforces strict rules at compile time. Unlike Objective-C’s GCD where everything is a runtime convention, Swift’s compiler refuses to build code that could race. That sounds great in theory and frustrating in practice, because the compiler errors often point at the symptom (await missing) rather than the design problem (the wrong isolation domain).
Three concepts drive almost every concurrency error you’ll hit. Isolation says who is allowed to touch a piece of mutable state — an actor, the @MainActor, a global actor, or nothing (nonisolated). Suspension points — every await — are where the current task can yield, and crucially, where other code on the same isolation domain can run. And Sendable is the boundary marker between isolation domains; only Sendable values may cross. Most “but my code looks right” bugs come from accidentally crossing one of these boundaries.
The other source of grief is Swift 5 vs Swift 6 mode. Swift 5 with -strict-concurrency=minimal lets @unchecked Sendable and implicit isolation pass through unnoticed. The moment you flip to -strict-concurrency=complete or open the project in Xcode 16 with Swift 6 mode, dozens of latent data races surface as errors. If your project “suddenly stopped compiling after the Xcode upgrade,” this is what happened.
awaitrequires an async context — you can only callasyncfunctions from otherasyncfunctions,Taskbodies, orasyncclosures.- Actor isolation prevents data races — accessing an actor’s properties from outside the actor requires
await. - UI work must happen on
@MainActor— UIKit and SwiftUI components are@MainActor-isolated. Sendableconformance is required for concurrent code — passing non-Sendabletypes across actor boundaries causes compile errors in strict concurrency checking mode.
Diagnostic Timeline
A senior dev’s first guess for “my async code doesn’t run” is usually “add await.” That fix works in 10% of cases. The other 90% need a different diagnosis.
Minute 0 — Where is the Task created? A Task {} block runs on whatever actor created it. A Task {} inside a SwiftUI View body inherits @MainActor. A Task.detached {} inherits nothing. If your “background” Task is actually pinned to MainActor and blocks on synchronous DB work, the UI freezes. Check with print(Thread.isMainThread) at the start of the Task body.
Minute 3 — Check MainActor isolation, not “await.” If you see “Publishing changes from background threads is not allowed” with SwiftUI, the cause is usually a @Published property on a class that is not @MainActor-isolated. Mark the entire ObservableObject (or @Observable class in iOS 17+) as @MainActor. Adding await MainActor.run around individual sets works but is the band-aid; isolate the whole view model instead.
Minute 7 — Check Sendable for captured types. If the compiler complains about “non-sendable type X passed across actor boundary,” look at what your Task closure captures. A class instance, a URLSession.DataTask, or a closure that captures self from a non-Sendable class will fail. The fix is usually one of: (a) convert the type to a struct, (b) make the class final with only let properties, (c) extract a Sendable snapshot before the boundary.
Minute 12 — Check Task cancellation propagation. If a Task “never finishes,” the parent might have been cancelled while a child Task was suspended in non-cooperative code. Long-running synchronous work doesn’t observe cancellation unless you sprinkle try Task.checkCancellation() inside it. URLSession.data(from:) does observe cancellation. Process.run() does not.
Minute 16 — Check withCheckedContinuation paths. If you bridge a callback API with withCheckedContinuation, every error path must call continuation.resume(...) exactly once. If you call it twice, the runtime traps. If you forget a path, the parent task hangs forever. Use withCheckedThrowingContinuation and convert callback errors into resume(throwing:).
Minute 20 — Check Swift mode. Run swift -version and look at your build settings for SWIFT_STRICT_CONCURRENCY and SWIFT_VERSION. A package that compiles cleanly in Swift 5 mode can produce hundreds of errors in Swift 6 mode. Migrate incrementally: enable strict checking per-target rather than flipping the whole project.
Fix 1: Create Tasks to Bridge Sync and Async Code
When you need to call async code from a synchronous context (like a UIViewController method or SwiftUI button action), create a Task:
// WRONG — can't await in non-async function
func viewDidLoad() {
super.viewDidLoad()
let users = await fetchUsers() // Compile error
}
// CORRECT — wrap in Task
override func viewDidLoad() {
super.viewDidLoad()
Task {
let users = await fetchUsers()
await MainActor.run {
self.tableView.reloadData()
}
}
}
// In SwiftUI — button actions can be async directly
Button("Load") {
Task {
await loadData()
}
}
// Or use .task modifier for view lifecycle
struct ContentView: View {
@State private var users: [User] = []
var body: some View {
List(users) { user in
Text(user.name)
}
.task {
// Runs when view appears, cancelled when view disappears
users = await fetchUsers()
}
}
}Task priorities and cancellation:
// Task with priority
let task = Task(priority: .userInitiated) {
await loadHeavyData()
}
// Cancel the task
task.cancel()
// Check cancellation inside the task
Task {
for item in largeDataset {
try Task.checkCancellation() // Throws if cancelled
await processItem(item)
}
}
// Detached task — doesn't inherit actor context or priority
Task.detached(priority: .background) {
// Runs without inheriting the calling actor's context
await performBackgroundWork()
}Fix 2: Fix Actor Isolation Errors
Actors protect their mutable state from concurrent access. All access from outside must be await-ed:
actor DataStore {
private var cache: [String: Data] = [:]
private var requestCount = 0
// Actor-isolated method — callable from outside with await
func store(key: String, data: Data) {
cache[key] = data
requestCount += 1
}
func retrieve(key: String) -> Data? {
return cache[key]
}
// nonisolated — callable without await (for synchronous, read-only work)
nonisolated var description: String {
"DataStore" // Can't access cache here — actor-isolated
}
}
// Usage — always await actor methods from outside
let store = DataStore()
Task {
await store.store(key: "user", data: userData)
let data = await store.retrieve(key: "user")
}
// WRONG — synchronous access from outside
store.cache["key"] = data // Compile error: actor-isolated propertyDesigning actor APIs:
actor NetworkManager {
private var activeRequests: Set<UUID> = []
private var session: URLSession
init() {
session = URLSession.shared
}
func fetch<T: Decodable>(url: URL) async throws -> T {
let id = UUID()
activeRequests.insert(id)
defer { activeRequests.remove(id) }
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NetworkError.invalidResponse
}
return try JSONDecoder().decode(T.self, from: data)
}
var requestCount: Int {
activeRequests.count
}
}
// Actor inheritance — actors can't be subclassed (they're final)
// Use protocols to share interface
protocol Cacheable: Actor {
func store(key: String, value: Any)
func retrieve(key: String) -> Any?
}Fix 3: Use @MainActor for UI Updates
Any code that touches UIKit, AppKit, or SwiftUI state must run on the main actor:
// Mark an entire class as MainActor-isolated
@MainActor
class UserViewModel: ObservableObject {
@Published var users: [User] = []
@Published var isLoading = false
@Published var error: Error?
// All methods run on main thread automatically
func loadUsers() async {
isLoading = true
defer { isLoading = false }
do {
// await suspends, potentially moving to background
// but the surrounding code is @MainActor
users = try await UserService.shared.fetchAll()
} catch {
self.error = error
}
}
}
// In a UIViewController — mark specific methods
class UserViewController: UIViewController {
@MainActor
func updateUI(with users: [User]) {
tableView.reloadData()
activityIndicator.stopAnimating()
}
override func viewDidLoad() {
super.viewDidLoad()
Task {
let users = await fetchUsers()
// Switch to main actor for UI update
await updateUI(with: users)
}
}
}
// Explicit MainActor.run for one-off cases
Task {
let data = await api.fetch()
await MainActor.run {
label.text = data.title
button.isEnabled = true
}
}Isolating async work from UI:
// Service layer — not @MainActor
actor APIService {
func fetchUsers() async throws -> [User] {
let (data, _) = try await URLSession.shared.data(from: usersURL)
return try JSONDecoder().decode([User].self, from: data)
}
}
// ViewModel — @MainActor, calls service
@MainActor
class UsersViewModel: ObservableObject {
@Published var users: [User] = []
private let service = APIService()
func load() async {
do {
// fetchUsers() runs in APIService actor context
// result is delivered back to @MainActor
users = try await service.fetchUsers()
} catch {
print("Error:", error)
}
}
}Fix 4: Structured Concurrency with async let and TaskGroup
Run multiple async operations concurrently using structured concurrency:
// async let — start concurrent tasks, collect results
func loadDashboard() async throws -> Dashboard {
// Both fetches start immediately and run concurrently
async let users = fetchUsers()
async let posts = fetchPosts()
async let stats = fetchStats()
// Await all results — if any throws, the others are cancelled
return try await Dashboard(
users: users,
posts: posts,
stats: stats
)
}
// Handle errors from individual concurrent tasks
func loadDashboardWithPartialFailure() async -> Dashboard {
async let users = fetchUsers()
async let posts = fetchPosts()
// Collect results independently
let fetchedUsers = (try? await users) ?? []
let fetchedPosts = (try? await posts) ?? []
return Dashboard(users: fetchedUsers, posts: fetchedPosts)
}
// TaskGroup — for dynamic concurrency (unknown count at compile time)
func processItems(_ items: [Item]) async throws -> [Result] {
try await withThrowingTaskGroup(of: Result.self) { group in
for item in items {
group.addTask {
try await processItem(item)
}
}
var results: [Result] = []
for try await result in group {
results.append(result)
}
return results
}
}
// Limit concurrency — don't fire 1000 requests at once
func fetchAll(ids: [Int]) async throws -> [User] {
try await withThrowingTaskGroup(of: User.self) { group in
var results: [User] = []
var index = 0
// Start initial batch
for _ in 0..<min(5, ids.count) {
group.addTask { try await fetch(id: ids[index]) }
index += 1
}
// As each finishes, add the next
for try await user in group {
results.append(user)
if index < ids.count {
let nextId = ids[index]
group.addTask { try await fetch(id: nextId) }
index += 1
}
}
return results
}
}Fix 5: Sendable and Data Race Safety
Swift 6 strict concurrency checking requires Sendable conformance for types passed between actors:
// Compile error in Swift 6: passing non-Sendable across actor boundary
class UserData { // Class is not Sendable by default
var name: String
init(name: String) { self.name = name }
}
actor Cache {
func store(_ user: UserData) { } // Error if UserData isn't Sendable
}
// Fix 1: Use a struct (value types are Sendable)
struct UserData: Sendable {
var name: String
var email: String
}
// Fix 2: Final class with only immutable state
final class UserData: Sendable {
let name: String // let = immutable = Sendable
let email: String
init(name: String, email: String) {
self.name = name
self.email = email
}
}
// Fix 3: @unchecked Sendable — opt out of checking (use with care)
final class UserData: @unchecked Sendable {
var name: String // You're promising thread safety yourself
private let lock = NSLock()
func updateName(_ newName: String) {
lock.withLock { name = newName }
}
}
// Sendable closures in async contexts
func performAsync(completion: @Sendable @escaping () -> Void) async {
await Task { completion() }.value
}Enable strict concurrency checking:
// Package.swift — enable Swift 6 concurrency checking
.target(
name: "MyApp",
swiftSettings: [
.enableExperimentalFeature("StrictConcurrency")
// Or for Swift 6:
// .swiftLanguageVersion(.v6)
]
)Fix 6: AsyncStream and Combining Async Sequences
Replace Combine publishers with AsyncStream for reactive patterns:
// Create an AsyncStream from a callback-based API
func locationUpdates() -> AsyncStream<CLLocation> {
AsyncStream { continuation in
let manager = CLLocationManager()
let delegate = LocationDelegate { location in
continuation.yield(location)
}
manager.delegate = delegate
manager.startUpdatingLocation()
continuation.onTermination = { _ in
manager.stopUpdatingLocation()
}
}
}
// Consume the stream
Task {
for await location in locationUpdates() {
print("New location:", location.coordinate)
// Stream ends when task is cancelled
}
}
// AsyncThrowingStream — when the source can fail
func dataStream(url: URL) -> AsyncThrowingStream<Data, Error> {
AsyncThrowingStream { continuation in
let task = URLSession.shared.dataTask(with: url) { data, _, error in
if let error = error {
continuation.finish(throwing: error)
} else if let data = data {
continuation.yield(data)
continuation.finish()
}
}
task.resume()
continuation.onTermination = { _ in task.cancel() }
}
}
// Combine multiple async sequences
func mergedUpdates() -> AsyncStream<Update> {
AsyncStream { continuation in
Task {
async let s1: Void = {
for await update in stream1() {
continuation.yield(update)
}
}()
async let s2: Void = {
for await update in stream2() {
continuation.yield(update)
}
}()
_ = await (s1, s2)
continuation.finish()
}
}
}Still Not Working?
Task not running at all — Task {} is fire-and-forget. If you’re not seeing it run, check: (1) the surrounding code is actually executing, (2) you’re not inadvertently cancelling the task (e.g., the object holding the task is deallocated), (3) the async function you’re calling isn’t deadlocking. Store the Task in a property if you need to cancel it or if the surrounding object might be deallocated.
Continuation never called — if you’re using withCheckedContinuation or withCheckedThrowingContinuation, the continuation must be called exactly once. Forgetting to call it (for example in an error path) hangs the async call forever:
// WRONG — continuation not called in error path
let result = await withCheckedThrowingContinuation { continuation in
someCallback { value, error in
if let error = error { return } // Forgot continuation.resume(throwing: error)!
continuation.resume(returning: value)
}
}
// CORRECT — always call continuation
let result = await withCheckedThrowingContinuation { continuation in
someCallback { value, error in
if let error = error {
continuation.resume(throwing: error)
return
}
continuation.resume(returning: value!)
}
}@MainActor and async interaction — a @MainActor-isolated async function suspends at each await, which allows the main thread to process other work during the suspension. This is generally correct behavior, but if you’re seeing unexpected interleaving of UI updates, it’s because other main-actor work can run during those await points. Use Task { @MainActor in ... } or wrap related updates in a single synchronous block to prevent interleaving.
Swift 5 vs Swift 6 concurrency rules — Swift 5 mode has relaxed Sendable checking. If your code compiles in Swift 5 mode but you see warnings about concurrency, enable -strict-concurrency=complete to surface issues before upgrading to Swift 6.
Cancellation never propagates into a child Task — Task.detached does not inherit the parent’s cancellation. If you start work with Task.detached {} from inside another Task, cancelling the parent will not cancel the detached child. Use a structured withTaskGroup if you need cancellation to flow, or store the detached task handle and call cancel() explicitly.
SwiftUI view’s .task modifier fires repeatedly — .task(id:) re-runs whenever the id changes, including on every view rebuild if the id is a computed property that produces a new value each render. Use a stable identity (a struct that conforms to Equatable) for the id, not an inline tuple or array, otherwise the previous task is cancelled and a new one starts on every redraw.
A function marked async blocks the calling thread — async does not implicitly move work off the current actor. If you call async work from @MainActor and it does CPU-bound math without an await, the main thread blocks for the entire body. Either insert await Task.yield() to break up the work, or move the heavy section into a non-main actor and await it from MainActor.
For related iOS issues, see Fix: Swift Fatal Error Unwrapping Nil, Fix: Kotlin Coroutine Not Executing, Fix: C# Async Deadlock, and Fix: C# Task Was Canceled.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.
Fix: Kafka Consumer Not Receiving Messages, Connection Refused, and Rebalancing Errors
How to fix Apache Kafka issues — consumer not receiving messages, auto.offset.reset, Docker advertised.listeners, max.poll.interval.ms rebalancing, MessageSizeTooLargeException, and KafkaJS errors.
Fix: Directus Not Working — API Returning 403, Items Not Appearing, or Flows Not Triggering
How to fix Directus issues — permissions, access policies, collections, REST and GraphQL APIs, file uploads, Flows automation, and self-hosted deployment.
Fix: PocketBase Not Working — Auth Failing, Real-time Subscriptions Broken, or Collection Rules Blocking Requests
How to fix PocketBase issues — authentication, collection access rules, real-time subscriptions, file uploads, relations, and self-hosted deployment.