Skip to content

Fix: Swift async/await Not Working — Task Not Running, Actor Isolation Error, or MainActor Crash

FixDevs ·

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:

  • await requires an async context — you can only call async functions from other async functions, Task bodies, or async closures. Calling await from a synchronous function is a compile error.
  • Actor isolation prevents data races — accessing an actor’s properties from outside the actor requires await. This is enforced at compile time, not runtime. Forgetting await when accessing actor-isolated state is the most common mistake.
  • UI work must happen on @MainActor — UIKit and SwiftUI components are @MainActor-isolated. Updating them from a background Task without await MainActor.run {} causes thread safety warnings and potential crashes.
  • Sendable conformance is required for concurrent code — passing non-Sendable types across actor boundaries causes compile errors in strict concurrency checking mode.

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 property

Designing 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 allTask {} 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. Fix warnings incrementally rather than suppressing them all with @unchecked Sendable.

For related iOS issues, see Fix: SwiftUI State Not Updating and Fix: URLSession Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles