DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI Async & Concurrency Patterns

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
}
Enter fullscreen mode Exit fullscreen mode

Correct pattern:

func load() async {
    let data = try? await Task.detached {
        await API.fetch()
    }.value

    await MainActor.run {
        self.user = data
    }
}
Enter fullscreen mode Exit fullscreen mode

Rule:
📌 Business logic on background threads. UI state updates on main.


⚡ 2. Use Task {} in Views (The Right Way)

.task {
    await viewModel.load()
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

.task { await vm.loadOnce() }
Enter fullscreen mode Exit fullscreen mode

🧵 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()
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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 }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

Great for:

  • chat
  • notifications
  • watching a database
  • live UI updates

📌 8. Never Run Async Work in Initializers

This is WRONG:

init() {
    Task { await load() }
}
Enter fullscreen mode Exit fullscreen mode

Creates untracked tasks → hard to cancel.
Correct:

struct HomeView: View {
    @StateObject var vm = HomeViewModel()

    var body: some View {
        content
            .task { await vm.load() }
    }
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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)