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

🔥 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:
- Detached task with strong self reference — Creates a retain cycle. The view controller never deallocates.
- Modifying UI from background thread —
Task.detacheddoesn't run on main actor. UI updates crash randomly. - 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:
- Inherits priority from the current context
- Inherits actor context (runs on main actor if called from main actor)
- Automatically cancelled when parent scope ends
- Inherits task-local values
When to use:
- Calling async code from sync context
- SwiftUI button actions
- Starting work that should cancel when the view disappears
- Pretty much always, unless you have a specific reason not to
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:
- Does NOT inherit priority
- Does NOT inherit actor context
- Does NOT automatically cancel
- Does NOT inherit task-local values
When to use:
- Long-running background work that should continue even if the caller finishes
- Work that should NOT run on main actor (CPU-intensive tasks)
- Fire-and-forget operations that don’t depend on caller’s context
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:
.high- User-initiated, needs to complete quickly.medium- Default.low- Deferrable work.background- Lowest priority, runs when system has spare resources.userInitiated- Explicit user action.utility- Long-running tasks with progress UI
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
**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 automaticallyThis 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 upExponential 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 { }**:
- ✅ Calling async code from sync context
- ✅ SwiftUI button actions
- ✅ Starting work that should respect the caller’s context
- ✅ 95% of the time
Use **Task.detached { }**:
- ✅ Long-running background work independent of caller
- ✅ CPU-intensive work that shouldn’t run on main actor
- ✅ Fire-and-forget operations
- ⚠️ Rare cases only
Use **Task.sleep**:
- ✅ Delays between retries
- ✅ Debouncing/rate limiting
- ✅ Polling intervals
- ✅ Animations/timing
- ❌ Never use
Thread.sleepin async code
Use **task.cancel()**:
- ✅ User navigated away
- ✅ Newer operation invalidates old one
- ✅ Timeout exceeded
- ✅ App going to background
Cancellation Checklist
Always:
- ✅ Check
Task.isCancelledor useTask.checkCancellation()in loops - ✅ Clean up resources in
deferblocks - ✅ Cancel tasks when views disappear (SwiftUI
.taskdoes this) - ✅ Store task references if you need to cancel them later
Never:
- ❌ Swallow cancellation errors with
try?unless you have a good reason - ❌ Assume cancelled tasks stop immediately
- ❌ Forget to cancel detached tasks
Priority Guidelines
**.high**/**.userInitiated**- User tapped a button, waiting for result**.medium**- Default, most work**.low**/**.utility**- Prefetching, background sync**.background**- Cleanup, non-urgent tasks
🎯 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! 🚀
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
Why I'm Rebuilding My Blog From Scratch (and Leaving Medium)
After years of publishing on someone else's platform, I'm moving my writing to a home I actually own. Here's the reasoning, and what I'm building instead.
ReadData Is the Model: The Most Ignored Part of AI
A beginner-friendly guide to why data quality beats model hype.
Read