Swift 6.2 Concurrency For SwiftUI Apps

Swift concurrency has always been powerful, but the first few versions made a lot of very normal app code feel louder than it needed to be. A SwiftUI app has tons of code that really does belong on the main actor: view models, UI state, button actions, navigation state, alerts, and so on. The tricky part is separating that from code that only feels nearby, like JSON decoding, image processing, indexing, searching, sorting, or any other expensive work.

Swift 6.2 helps by making that split more explicit. Apple describes the release as adding a more approachable concurrency model, including default main actor isolation, async functions that can stay on the caller’s actor, and the new @concurrent attribute for work that should hop away from the caller’s actor.

In a SwiftUI app, my rule of thumb is:

  • UI state can live on the main actor.
  • Async I/O is usually fine to start from the main actor because await gives the executor back.
  • Expensive CPU work should not happen on the main actor.
  • Task {} is not a magic background thread.
  • Reach for @concurrent or a dedicated actor when work should truly run concurrently.

Main actor by default

If you enable default actor isolation to MainActor, unannotated code in that target is inferred to be main actor isolated. In Xcode this is a build setting. In a Swift package, the setting looks like this:

// swift-tools-version: 6.2
import PackageDescription

let package = Package(
    name: "SearchFeature",
    targets: [
        .target(
            name: "SearchFeature",
            swiftSettings: [
                .defaultIsolation(MainActor.self),
                .enableUpcomingFeature("NonisolatedNonsendingByDefault")
            ]
        )
    ]
)

That setting is a really nice fit for SwiftUI feature code. For example, this model is mostly UI state, so main actor isolation is exactly what we want:

import Observation
import SwiftUI

@Observable
final class SearchModel {
    var query = ""
    var results: [SearchResult] = []
    var isLoading = false
    var errorMessage: String?

    private let service = SearchService()

    func submitSearch() {
        let query = cacheKey(for: query)

        Task {
            await loadResults(for: query)
        }
    }

    private func loadResults(for query: String) async {
        isLoading = true
        errorMessage = nil

        do {
            results = try await service.search(for: query)
        } catch {
            errorMessage = error.localizedDescription
        }

        isLoading = false
    }

    nonisolated private func cacheKey(for query: String) -> String {
        query
            .trimmingCharacters(in: .whitespacesAndNewlines)
            .lowercased()
    }
}

The nonisolated helper is a tiny detail, but it is a good habit. The method does not read or write any model state, so it does not need the main actor. If I accidentally try to touch results or isLoading inside that helper, the compiler will stop me.

Async does not always mean background

Here is where I think people get surprised. Starting a Task from a main actor context does not automatically mean every line inside the task is background work. It can still run on the main actor until it reaches work that actually suspends or hops somewhere else.

This service performs network I/O, then sends decoding to a concurrent function. Because this target has default MainActor isolation enabled, the service, response models, and decoder are marked nonisolated. Otherwise even a simple Decodable conformance can become main actor isolated, which is exactly what we do not want inside concurrent decoding code.

import Foundation

nonisolated struct SearchService: Sendable {
    func search(for query: String) async throws -> [SearchResult] {
        var components = URLComponents(string: "https://example.com/search")!
        components.queryItems = [
            URLQueryItem(name: "q", value: query)
        ]

        guard let url = components.url else {
            throw URLError(.badURL)
        }

        let (data, _) = try await URLSession.shared.data(from: url)
        return try await SearchDecoder.decode(data)
    }
}

nonisolated struct SearchResult: Decodable, Identifiable, Sendable {
    let id: UUID
    let title: String
}

nonisolated private struct SearchResponse: Decodable {
    let results: [SearchResult]
}

nonisolated enum SearchDecoder {
    @concurrent
    static func decode(_ data: Data) async throws -> [SearchResult] {
        try JSONDecoder()
            .decode(SearchResponse.self, from: data)
            .results
    }
}

The network request is not the scary part. URLSession.shared.data(from:) suspends while the system does the work. The part I want to be careful with is decoding. Small JSON payloads are usually fine wherever, but once decoding gets big enough to affect scrolling or typing responsiveness, I want it away from the main actor.

That is what @concurrent communicates. It says this async function should switch away from the actor that called it and run concurrently. In practice, this makes the intent much clearer than hiding the same work in Task.detached.

When to use nonisolated

I like to use nonisolated for pure helpers on otherwise main actor isolated types:

@Observable
final class ProfileModel {
    var displayName = ""

    nonisolated func normalizedUsername(_ username: String) -> String {
        username
            .trimmingCharacters(in: .whitespacesAndNewlines)
            .lowercased()
    }
}

This keeps the model ergonomic for SwiftUI without making every helper part of the UI synchronization story. The tradeoff is exactly the one we want: a nonisolated method cannot touch actor isolated state unless you explicitly hop back to the actor.

For async functions, Swift 6.2’s NonisolatedNonsendingByDefault upcoming feature changes the behavior so nonisolated async functions run on the caller’s actor by default. If you need the old “hop to the generic executor” behavior, that is where @concurrent comes in.

A small checklist

When I am writing new SwiftUI code in Swift 6.2, this is the checklist I use:

  • Does this property drive the UI? Keep it on the main actor.
  • Does this method only normalize input or build a cache key? Mark it nonisolated.
  • Does this method wait on network or disk I/O? Make it async and call it with await.
  • Does this method parse, resize, hash, sort, or do real CPU work? Consider @concurrent or a dedicated actor.
  • Does data cross actor boundaries? Make the models Sendable when possible.

That mental model has made Swift 6.2 feel a lot less mysterious to me. Main actor by default is not a license to do everything on the main actor. It is a better default for app state, as long as we are still intentional about the work that does not belong there.

Further reading: