All articles
iOS8 min read

Swift Tasks: The Complete Guide That Separates Hobbyists from Professionals

Everything you need to know about Task creation, Task.sleep, cancellation, and avoiding the async pitfalls that crash production apps

K
Karan Pal
Author
Swift Tasks: Complete Guide
Swift Tasks: Complete Guide

🔥 The Task Mistakes Crashing Production

So last year, our app started crashing in production. Not consistently — just randomly. Users would tap a button, the app would freeze for a second, then crash. No error logs. No stack trace. Nothing useful.

After two days of debugging, I found the problem. Someone had written this:

class ProfileViewController: UIViewController {
    var userID: String?
    
    func loadUserProfile() {
        Task.detached {
            guard let id = self.userID else { return }
            
            let profile = try await self.apiService.fetchProfile(userId: id)
            
            self.nameLabel.text = profile.displayName
            self.avatarImageView.image = try await self.loadAvatar(from: profile.avatarURL)
            self.updateUI(with: profile)
        }
    }
}

Looks innocent, right? Wrong. This code has three critical bugs:

  1. Detached task with strong self reference — Creates a retain cycle. The view controller never deallocates.
  2. Modifying UI from background threadTask.detached doesn't run on main actor. UI updates crash randomly.
  3. No cancellation — User navigates away? Task keeps running, wasting battery and bandwidth.

Here’s what actually happened: Users would tap the profile, immediately go back, then tap again. Each tap created a new detached task. Those tasks never cancelled. Memory usage climbed. UI updates happened on background threads. Eventually… crash.

The fix? Use structured tasks with proper cancellation:

class ProfileViewController: UIViewController {
    var userID: String?
    private var loadingTask: Task<Void, Never>?
    
    func loadUserProfile() {
        // Cancel any existing load
        loadingTask?.cancel()
        
        loadingTask = Task { @MainActor in
            guard let id = userID else { return }
            
            do {
                let profile = try await apiService.fetchProfile(userId: id)
                
                // Check if we got cancelled while fetching
                if Task.isCancelled { return }
                
                nameLabel.text = profile.displayName
                avatarImageView.image = try await loadAvatar(from: profile.avatarURL)
                updateUI(with: profile)
            } catch {
                showErrorAlert(message: error.localizedDescription)
            }
        }
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        loadingTask?.cancel()
    }
}

Now the task cancels when you navigate away. UI updates happen on main thread. No retain cycles. No crashes.

This is the difference between hobbyist code and professional code. Let’s make sure you know the difference.

📺 I’m working on a debugging series showing real production bugs like this one. Check it out on Swift Pal: _https://youtube.com/@swift-pal_

⚡ Task Creation: Structured vs Detached

Tasks come in two flavors: structured and unstructured. Choosing the wrong one causes bugs.

Structured Tasks (Use 95% of the time)

// Basic structured task
Task {
    let weather = try await fetchWeatherData()
    print("Current temp: \(weather.temperature)°")
}

What makes it structured:

When to use:

Real example — SwiftUI:

struct WeatherView: View {
    @State private var currentTemp: Double?
    @State private var isRefreshing = false
    
    var body: some View {
        VStack {
            if let temp = currentTemp {
                Text("\(temp, specifier: "%.1f")°")
                    .font(.largeTitle)
            }
            
            Button("Refresh") {
                Task {
                    isRefreshing = true
                    currentTemp = try? await weatherService.getCurrentTemp()
                    isRefreshing = false
                }
            }
            .disabled(isRefreshing)
        }
    }
}

The Task { } runs on main actor because body runs on main actor. Perfect for updating @State.

Unstructured Tasks (Use rarely)

// Detached task
Task.detached {
    let largeLogs = await processHeavyLogging()
    await persistToDatabase(largeLogs)
}

What makes it detached:

When to use:

Real example — Background log processing:

actor LogProcessor {
    private var pendingLogs: [LogEntry] = []
    
    func submitLog(_ entry: LogEntry) {
        pendingLogs.append(entry)
        
        // Process in background, don't block the caller
        if pendingLogs.count >= 50 {
            let logsToProcess = pendingLogs
            pendingLogs.removeAll()
            
            Task.detached {
                await self.processBatch(logsToProcess)
            }
        }
    }
    
    private func processBatch(_ logs: [LogEntry]) async {
        // Heavy processing off main actor
        let compressed = compressLogs(logs)
        try? await uploadToServer(compressed)
    }
}

Notice the detached task doesn’t inherit the actor context. It runs independently.

Task Priorities

You can set priority explicitly:

// High priority
Task(priority: .high) {
    let criticalData = try await fetchCriticalData()
    await updateDashboard(with: criticalData)
}

// Background priority
Task(priority: .background) {
    await cleanupTempFiles()
}

Priority levels:

Real example:

class MediaLibrary {
    func refreshLibrary(userInitiated: Bool) {
        let priority: TaskPriority = userInitiated ? .userInitiated : .background
        
        Task(priority: priority) {
            let mediaItems = await scanMediaFiles()
            await updateDatabase(with: mediaItems)
            
            if userInitiated {
                await showRefreshComplete()
            }
        }
    }
}

User tapped refresh? High priority. Background sync? Low priority.

The Golden Rule

Default to structured **Task { }** unless you have a specific reason to detach.

Structured tasks prevent most async bugs. Detached tasks are powerful but dangerous — like unsafe pointers. Use sparingly.

😴 Task.sleep Without the Headaches

Task.sleep is how you pause async work without blocking threads. But there's some weird stuff about it.

The Basics

// Sleep for 2 seconds
try await Task.sleep(nanoseconds: 2_000_000_000)

// Yeah, nanoseconds. Welcome to hell.

Why nanoseconds? Because Apple wants precision. A second is 1 billion nanoseconds (1,000,000,000).

Doing the math:

// 1 second
try await Task.sleep(nanoseconds: 1_000_000_000)

// 500 milliseconds (half second)
try await Task.sleep(nanoseconds: 500_000_000)

// 100 milliseconds
try await Task.sleep(nanoseconds: 100_000_000)

This sucks for readability. Let’s fix it:

extension Task where Success == Never, Failure == Never {
    static func sleep(seconds: Double) async throws {
        try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
    }
    
    static func sleep(milliseconds: Int) async throws {
        try await Task.sleep(nanoseconds: UInt64(milliseconds * 1_000_000))
    }
}

// Now you can write:
try await Task.sleep(seconds: 2.5)
try await Task.sleep(milliseconds: 500)

Much better.

iOS 16+ has Duration:

// If you're targeting iOS 16+
try await Task.sleep(for: .seconds(2))
try await Task.sleep(for: .milliseconds(500))
try await Task.sleep(until: .now + .seconds(5))

Way more readable. Use this if you can.

How Sleep Actually Works

Here’s what people get wrong: **Task.sleep** doesn't block the thread.

// This doesn't freeze the app
Task {
    print("Starting sleep")
    try await Task.sleep(seconds: 3)
    print("Done sleeping")
}
print("This prints immediately, not after 3 seconds")

The task suspends. The thread is free to do other work. When the sleep duration is up, the task resumes.

Compare to Thread.sleep (never use this in async code):

// DON'T DO THIS - blocks the thread
Task {
    print("Starting")
    Thread.sleep(forTimeInterval: 3)  // ❌ Thread is blocked, can't do other work
    print("Done")
}

Thread.sleep freezes the thread. If you do this on main thread, your app hangs. Don't do it.

Sleep is Cancellable

If the task gets cancelled during sleep, Task.sleep throws CancellationError:

let sleepyTask = Task {
    do {
        print("Going to sleep...")
        try await Task.sleep(seconds: 10)
        print("Woke up naturally")
    } catch is CancellationError {
        print("Got woken up early!")
    }
}

// Cancel after 2 seconds
Task {
    try await Task.sleep(seconds: 2)
    sleepyTask.cancel()
}

// Output:
// Going to sleep...
// Got woken up early!

This is actually useful. You can interrupt long sleeps when needed.

Real-World Example: Retry with Delay

func fetchWithRetry<T>(
    maxAttempts: Int = 3,
    delay: Double = 1.0,
    operation: () async throws -> T
) async throws -> T {
    var currentAttempt = 0
    
    while currentAttempt < maxAttempts {
        do {
            return try await operation()
        } catch {
            currentAttempt += 1
            
            if currentAttempt >= maxAttempts {
                throw error  // No more retries, give up
            }
            
            print("Attempt \(currentAttempt) failed, retrying in \(delay)s...")
            try await Task.sleep(seconds: delay)
        }
    }
    
    fatalError("Unreachable")
}

// Usage
let userData = try await fetchWithRetry {
    try await apiClient.fetchUserProfile(id: "USR-1234")
}

🛑 Cancellation That Actually Works

Cancellation in Swift is cooperative. The task doesn’t just stop — it has to check if it’s been cancelled and clean up accordingly.

This confuses people. Let’s fix that.

The Three Ways to Check Cancellation

  1. **Task.isCancelled** - Manual check
func processLargeDataset(_ items: [DataItem]) async throws {
    for item in items {
        if Task.isCancelled {
            print("Cancelled, stopping processing")
            return
        }
        
        await processItem(item)
    }
}

Returns true if cancelled, false otherwise. You decide what to do.

2\. **Task.checkCancellation()** - Automatic throw

func processLargeDataset(_ items: [DataItem]) async throws {
    for item in items {
        try Task.checkCancellation()  // Throws if cancelled
        await processItem(item)
    }
}

Throws CancellationError if the task is cancelled. Cleaner if you want to bail out immediately.

3\. Awaiting a throwing operation

Many async operations check cancellation automatically:

// Task.sleep throws if cancelled
try await Task.sleep(seconds: 1)

// URLSession checks cancellation
let (data, _) = try await URLSession.shared.data(from: url)

// Your custom operations can too
let result = try await someAsyncOperation()

If the task is cancelled, these throw. You don’t have to check manually.

How to Cancel a Task

Just call .cancel():

let downloadTask = Task {
    try await downloadLargeFile(from: fileURL)
}

// Later...
downloadTask.cancel()

Important: Calling cancel() doesn't stop the task immediately. It just sets a flag. The task needs to check that flag and clean up.

Cancellation Propagates Down

When you cancel a parent task, all child tasks get cancelled too:

let parentTask = Task {
    async let download1 = downloadFile(url1)
    async let download2 = downloadFile(url2)
    async let download3 = downloadFile(url3)
    
    let results = try await [download1, download2, download3]
    return results
}

// Cancel the parent
parentTask.cancel()

// All three downloads get cancelled automatically

This is why structured concurrency is powerful. You don’t have to manually track and cancel every child task.

Cancellation Doesn’t Propagate Up

If a child task is cancelled, the parent doesn’t know unless the child throws:

Task {
    let childTask = Task {
        try await Task.sleep(seconds: 10)
        return "Done"
    }
    
    childTask.cancel()
    
    // This still runs, parent isn't cancelled
    print("Parent task still running")
}

Proper Cleanup on Cancellation

Always clean up resources when cancelled:

func downloadWithCancellation(from url: URL) async throws -> Data {
    let downloadTask = URLSession.shared.dataTask(with: url)
    
    defer {
        if Task.isCancelled {
            downloadTask.cancel()  // Clean up URLSession task
        }
    }
    
    let (data, _) = try await URLSession.shared.data(from: url)
    return data
}

Use defer to ensure cleanup happens even if you return early or throw.

🔄 Essential Patterns You’ll Actually Use

Alright, enough theory. Here are the patterns you’ll use in real apps.

Pattern 1: Timeout Wrapper

Wrap any async operation with a timeout:

enum TimeoutError: Error {
    case timedOut
}

func withTimeout<T>(
    seconds: TimeInterval,
    operation: @escaping () async throws -> T
) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
        // Add the actual operation
        group.addTask {
            try await operation()
        }
        
        // Add timeout task
        group.addTask {
            try await Task.sleep(seconds: seconds)
            throw TimeoutError.timedOut
        }
        
        // First one to complete wins
        let result = try await group.next()!
        
        // Cancel the loser
        group.cancelAll()
        
        return result
    }
}

// Usage
do {
    let profile = try await withTimeout(seconds: 5) {
        try await apiClient.fetchUserProfile(id: "USR-7890")
    }
    print("Got profile: \(profile.username)")
} catch TimeoutError.timedOut {
    print("Request took too long, giving up")
} catch {
    print("Request failed: \(error)")
}

Now any operation can have a timeout. Beautiful.

Pattern 2: Retry with Exponential Backoff

Keep trying, but wait longer between each attempt:

func withRetry<T>(
    maxAttempts: Int = 3,
    initialDelay: TimeInterval = 1.0,
    multiplier: Double = 2.0,
    operation: () async throws -> T
) async throws -> T {
    var currentAttempt = 0
    var currentDelay = initialDelay
    
    while true {
        do {
            return try await operation()
        } catch {
            currentAttempt += 1
            
            if currentAttempt >= maxAttempts {
                throw error  // Out of retries
            }
            
            print("Attempt \(currentAttempt) failed. Retrying in \(currentDelay)s...")
            try await Task.sleep(seconds: currentDelay)
            
            currentDelay *= multiplier  // Exponential backoff
        }
    }
}

// Usage
let apiResponse = try await withRetry(maxAttempts: 4, initialDelay: 0.5) {
    try await networkClient.sendRequest(endpoint: "/api/purchase")
}

// Retry delays: 0.5s, 1s, 2s, then give up

Exponential backoff prevents hammering a struggling server.

Pattern 3: Debouncing (Search as You Type)

Only perform the operation after user stops typing:

actor Debouncer {
    private var currentTask: Task<Void, Never>?
    
    func debounce(
        delay: TimeInterval,
        operation: @escaping () async -> Void
    ) {
        // Cancel previous task
        currentTask?.cancel()
        
        // Create new task
        currentTask = Task {
            try? await Task.sleep(seconds: delay)
            
            if !Task.isCancelled {
                await operation()
            }
        }
    }
}

// Usage in SwiftUI
class SearchViewModel: ObservableObject {
    @Published var searchQuery = ""
    @Published var searchResults: [Product] = []
    
    private let debouncer = Debouncer()
    
    func searchQueryChanged(_ newQuery: String) {
        searchQuery = newQuery
        
        Task {
            await debouncer.debounce(delay: 0.3) {
                await self.performActualSearch(query: newQuery)
            }
        }
    }
    
    private func performActualSearch(query: String) async {
        guard !query.isEmpty else {
            await MainActor.run { searchResults = [] }
            return
        }
        
        do {
            let results = try await searchAPI.search(query: query)
            await MainActor.run {
                self.searchResults = results
            }
        } catch {
            print("Search failed: \(error)")
        }
    }
}

User types “macbook pro” → Only searches once they stop typing for 300ms. Saves tons of API calls.

🎬 Quick Reference & Best Practices

Alright, let’s wrap this up with a quick decision guide.

When to Use What

Use **Task { }**:

Use **Task.detached { }**:

Use **Task.sleep**:

Use **task.cancel()**:

Cancellation Checklist

Always:

Never:

Priority Guidelines

🎯 Final Thoughts

Look, tasks aren’t complicated. But the details matter.

Use structured Task { } by default. Cancel tasks when their context disappears. Check cancellation in loops. Don't block the main thread. Clean up resources.

Get these fundamentals right, and you’ll avoid 99% of async bugs.

The difference between hobbyist code and professional code? Professionals think about cancellation, memory management, and edge cases. Hobbyists just hope it works.

🎉 Enjoyed this article? Your support means the world to me!

💬 Drop a comment below! I love hearing about your experiences and answering questions

🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal

💼 Let’s connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal

Happy coding! 🚀

● The newsletter

New articles, straight to your inbox.

No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.

Keep reading