SwiftUI has embraced Swift’s structured concurrency model — and right now, async/await is the correct way to handle almost everything:
- API calls
- database reads
- image loading
- background work
- delayed UI effects
- debouncing & throttling
- task cancellation
- view-triggered async actions
But many SwiftUI devs still hit issues like:
- async tasks running on the main thread
- UI freezing during network calls
- duplicated tasks on view refresh
- memory leaks from long-living tasks
- async work getting cancelled too early
- missing cancellation handling
This guide shows the real-world async patterns you should use in every SwiftUI app.
🧠 1. Use @MainActor ONLY for UI Updates
A common mistake is doing async work on the main actor:
@MainActor
func load() async {
let data = await API.fetch() // ❌ expensive work on main thread
}
Correct pattern:
func load() async {
let data = try? await Task.detached {
await API.fetch()
}.value
await MainActor.run {
self.user = data
}
}
Rule:
📌 Business logic on background threads. UI state updates on main.
⚡ 2. Use Task {} in Views (The Right Way)
.task {
await viewModel.load()
}
But don’t do this:
❌ calling long async operations directly in the View
❌ storing tasks inside the View
❌ creating multiple loads when the view reappears
If you need one-time loading, put it in the ViewModel:
@Observable
class HomeViewModel {
var hasLoaded = false
func loadOnce() async {
guard !hasLoaded else { return }
hasLoaded = true
await load()
}
}
Usage:
.task { await vm.loadOnce() }
🧵 3. Handle Task Cancellation Properly
Every SwiftUI .task is automatically cancellable when the view disappears.
To handle it:
func loadData() async throws -> Data {
try Task.checkCancellation()
return try await API.fetch()
}
If a parent view pops off-screen → no wasted network calls.
🔄 4. Debounce & Throttle (The Clean Swift Concurrency Way)
Debounce (typing search bar)
func debounce(seconds: Double) async {
currentTask?.cancel()
currentTask = Task {
try await Task.sleep(for: .seconds(seconds))
await performSearch()
}
}
Throttle (scroll or drag events)
func throttle(seconds: Double) {
guard canRun else { return }
canRun = false
Task {
await action()
try await Task.sleep(for: .seconds(seconds))
canRun = true
}
}
Essential for:
- search fields
- sliders
- scroll-driven effects
🖼 5. Async Image Loading (Fast, Cached, Safe)
@Observable
class ImageLoader {
var image: UIImage?
private var task: Task<Void, Never>?
func load(url: URL) {
task?.cancel()
task = Task {
if let cached = ImageCache.shared[url] {
await MainActor.run { self.image = cached }
return
}
guard let (data, _) = try? await URLSession.shared.data(from: url) else { return }
let img = UIImage(data: data)
ImageCache.shared[url] = img
await MainActor.run { self.image = img }
}
}
}
This avoids:
- flickering
- duplicated downloads
- wasted memory
- task races
🧩 6. Using TaskGroup for Parallel Work
Example: load multiple user profiles simultaneously.
func loadProfiles(ids: [Int]) async -> [Profile] {
await withTaskGroup(of: Profile?.self) { group in
for id in ids {
group.addTask { try? await API.profile(id) }
}
var results: [Profile] = []
for await profile in group {
if let profile { results.append(profile) }
}
return results
}
}
Parallel = faster.
🧱 7. Long-Lived Tasks (e.g., live updates, sockets)
Use a stored task + cancellation:
var liveTask: Task<Void, Never>?
func startLiveUpdates() {
liveTask?.cancel()
liveTask = Task {
for await event in socket.events {
await MainActor.run { self.events.append(event) }
}
}
}
func stopLiveUpdates() {
liveTask?.cancel()
}
Great for:
- chat
- notifications
- watching a database
- live UI updates
📌 8. Never Run Async Work in Initializers
This is WRONG:
init() {
Task { await load() }
}
Creates untracked tasks → hard to cancel.
Correct:
struct HomeView: View {
@StateObject var vm = HomeViewModel()
var body: some View {
content
.task { await vm.load() }
}
}
Let the view lifecycle create tasks, not initializers.
🧭 9. Async + NavigationStack (clean pattern)
func openDetails() async {
isLoading = true
let user = try? await API.user(id)
isLoading = false
router.push(.details(user))
}
Async → state update → navigation.
No UI blocking. No double pushes.
🏎 10. Performance Checklist (Async Edition)
✔ Do:
- run work in background Tasks
- update UI on main actor
- use .task for view lifecycle loading
- use cancellation checks
- debounce fast input
- throttle rapid gestures
- use TaskGroup for parallel work
❌ Avoid:
- async operations in initializers
- blocking the main thread
- multiple loads triggered by view refresh
- never-cancelled long-running tasks
- storing huge state in @State
- async operations in computed properties
🚀 Final Thoughts
SwiftUI + Swift concurrency is incredibly powerful — if you use it the right way.
With these patterns, you get:
- smoother UI
- fewer bugs
- faster lists & scroll
- no duplicated tasks
- clean async architecture
- safe cancellation
- better performance
- more scalable code
Top comments (0)