Fix: Swift async/await Not Working — Task Not Running, Actor Isolation Error, or MainActor Crash
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:
awaitrequires an async context — you can only callasyncfunctions from otherasyncfunctions,Taskbodies, orasyncclosures. Callingawaitfrom 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. Forgettingawaitwhen 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 backgroundTaskwithoutawait MainActor.run {}causes thread safety warnings and potential crashes. Sendableconformance is required for concurrent code — passing non-Sendabletypes 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 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. 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.
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: Better Auth Not Working — Login Failing, Session Null, or OAuth Callback Error
How to fix Better Auth issues — server and client setup, email/password and OAuth providers, session management, middleware protection, database adapters, and plugin configuration.
Fix: BullMQ Not Working — Jobs Not Processing, Workers Not Starting, or Redis Connection Failing
How to fix BullMQ issues — queue and worker setup, Redis connection, job scheduling, retry strategies, concurrency, rate limiting, event listeners, and dashboard monitoring.