TekinAli ⎇ main

Main actor, @concurrent and [weak self]: concurrency habits for Swift 6.2

How Swift 6.2's default main-actor isolation, nonisolated(nonsending) and @concurrent change where your code runs, why async does not mean background, and when [weak self] in a Task still matters.

Two habits from the GCD era are still everywhere in Swift code: wrapping every UI update in DispatchQueue.main.async, and putting [weak self] on every closure. Under Swift concurrency both are often unnecessary, and sometimes they hide real bugs. A Task can freeze the UI because someone assumed async means "background". A long-running loop can keep a view controller alive forever.

Swift 6.2 (released September 15, 2025) changed the defaults enough that both habits are worth revisiting. Swift 6.4 shipped in September 2026, but the model in this article is the one 6.2 introduced. Below I cover where code actually runs, what 6.2 changes, how I recommend writing UI-facing code today, and when a weak capture still earns its place.

From threads to actors

Under GCD, the rules were simple and entirely manual. UIKit must be touched on the main thread, so every completion handler ended with DispatchQueue.main.async { self.label.text = ... }. Escaping closures could create retain cycles, so [weak self] became a reflex, followed by guard let self or self?. everywhere.

Swift concurrency (usable back to iOS 13) replaced "which thread" with "which actor". @MainActor code runs on the main thread, and the compiler checks every crossing between isolation domains. The Swift 6 language mode turns many data-race mistakes into compile-time errors. The old snippets still compile in this world. They are just noisy, and sometimes subtly wrong.

Async is not background

The most common misunderstanding fits in a few lines. Take a view model that builds a CSV export:

final class LegacyExportModel: ObservableObject {
    @Published var csv = ""

    func buildCSV(rows: [Row]) async {
        var output = "id,name,amount\n"
        for row in rows {                       // seconds of CPU work for a large export
            output += "\(row.id),\(row.name),\(row.amount)\n"
        }
        csv = output
    }
}

// In a SwiftUI button action:
Task { await model.buildCSV(rows: rows) }

It is easy to expect buildCSV to run in the background because it is async. Whether it does depends on two rules:

  1. Task { } inherits the actor of the context that creates it. A button action in a SwiftUI view runs on the main actor, so the task starts there.
  2. Where a non-isolated async function runs has changed over time. Before Swift 5.7 it was not clearly specified. SE-0338 describes the implementation of that time: such functions "never intentionally change the current executor". So code like this kept running on the main actor and froze the UI.

SE-0338 (Swift 5.7) defined the rule: non-isolated async functions hop off the actor to the global concurrent executor. That avoided this freeze, but it made code harder to reason about. Every call into a non-isolated async function crossed an isolation boundary, and many of the Sendable errors people hit in Swift 6 come from exactly that.

What Swift 6.2 changes

Swift 6.2 introduced two opt-in changes. Both are accepted and implemented proposals.

SE-0461, nonisolated(nonsending) and @concurrent. Under the upcoming feature NonisolatedNonsendingByDefault, a non-isolated async function runs on the caller's actor by default. You can spell that explicitly as nonisolated(nonsending). When you really want to leave the actor, you mark the function @concurrent. With this feature on, buildCSV above runs on the main actor again, and this time that is the documented behavior. The fix is to say what you mean:

nonisolated struct Row: Sendable {
    let id: Int
    let name: String
    let amount: Decimal
}

@concurrent
func makeCSV(from rows: [Row]) async -> String {
    var output = "id,name,amount\n"
    for row in rows {
        output += "\(row.id),\(row.name),\(row.amount)\n"
    }
    return output
}

@MainActor
@Observable
final class ExportModel {
    private(set) var csv = ""

    func export(_ rows: [Row]) async {
        csv = await makeCSV(from: rows)     // UI stays responsive, result lands on the main actor
    }
}

SE-0466, default actor isolation. A module can opt in to inferring @MainActor for all unannotated code with the compiler flag -default-isolation MainActor. The Swift 6.2 release announcement presents it as a good fit for scripts, UI code and other executable targets. In Xcode, the build setting is Default Actor Isolation (SWIFT_DEFAULT_ACTOR_ISOLATION). A separate Approachable Concurrency setting (SWIFT_APPROACHABLE_CONCURRENCY) turns on a bundle of upcoming features that includes NonisolatedNonsendingByDefault. In SwiftPM:

// swift-tools-version: 6.2
.target(
    name: "AppFeature",
    swiftSettings: [
        .defaultIsolation(MainActor.self),
        .enableUpcomingFeature("NonisolatedNonsendingByDefault")
    ]
)

My recommendation: turn both on for the app target and UI feature modules. Keep networking, persistence and other library modules nonisolated by default, and be deliberate about what crosses the boundary.

Writing UI code without DispatchQueue.main

Here is a feed screen written in the style I recommend today. It works with or without default main-actor isolation, because the important parts are annotated:

import SwiftUI
import Observation

nonisolated struct Post: Decodable, Sendable, Identifiable {
    let id: Int
    let title: String
}

nonisolated struct FeedAPI: Sendable {
    func fetchData() async throws -> Data {
        let url = URL(string: "https://example.com/feed.json")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return data
    }
}

@concurrent
func decodePosts(from data: Data) async throws -> [Post] {
    try JSONDecoder().decode([Post].self, from: data)   // off the main actor
}

@MainActor
@Observable
final class FeedModel {
    private(set) var posts: [Post] = []
    private(set) var errorMessage: String?
    private let api = FeedAPI()

    func refresh() async {
        do {
            let data = try await api.fetchData()
            posts = try await decodePosts(from: data)   // back on the main actor, no dispatch
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

struct FeedView: View {
    @State private var model = FeedModel()

    var body: some View {
        List(model.posts) { Text($0.title) }
            .task { await model.refresh() }        // iOS 15+
            .refreshable { await model.refresh() } // iOS 15+
    }
}

There is no DispatchQueue.main and no MainActor.run. The model is @MainActor, so assigning posts is safe by construction. nonisolated on a type (SE-0449, Swift 6.1) keeps it out of the module's default main-actor isolation, so it can be used freely off the main actor.

Bridging callbacks: MainActor.run vs. @MainActor closures

You still need a bridge sometimes, typically from a completion-handler API you do not control:

  • In a synchronous callback, use Task { @MainActor in ... }.
  • In a non-isolated async function, await MainActor.run { ... } (iOS 13+) works. But usually the cleaner fix is to make the function you call @MainActor and simply await it.
  • If a callback is documented to arrive on the main thread, MainActor.assumeIsolated { ... } (iOS 13+) tells the compiler so. If the assumption is wrong, it stops the app with a fatal error.

When [weak self] still matters

A Task holds its closure, and everything the closure captures, until the task completes. Then it releases them. For a request that finishes in a second, capturing self strongly only means the object lives a little longer. That is not a leak, so [weak self] there is mostly noise.

It becomes a real problem when the task never finishes on its own:

final class TickerViewController: UIViewController {
    private let service = PriceService()
    private let priceLabel = UILabel()
    private var tickerTask: Task<Void, Never>?

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        let stream = service.prices()               // AsyncStream<Price>
        tickerTask = Task { [weak self] in
            for await price in stream {
                guard let self else { return }      // strong only for this iteration
                self.priceLabel.text = price.value.formatted()
            }
        }
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        tickerTask?.cancel()                        // ends the loop
        tickerTask = nil
    }
}

The view controller owns the task, and an endless loop would keep self alive forever if it captured it strongly. Two details matter. The guard let self is inside the loop, so self is held only while one element is handled. And the task is cancelled explicitly. In SwiftUI, prefer .task { for await ... }: it is cancelled automatically when the view disappears.

Pitfalls

  • guard let self at the top of the task. Writing Task { [weak self] in guard let self else { return }; for await ... } makes self strong for the whole loop, so the weak capture does nothing.
  • Weak in the inner closure only. loader.load { result in Task { @MainActor [weak self] in ... } } still captures self strongly in the outer closure. Recent compilers warn about exactly this; Swift 6.4 does, for example. Put [weak self] on the outermost escaping closure.
  • Assuming async means background. With NonisolatedNonsendingByDefault, it explicitly does not. Use @concurrent for CPU-heavy work. I prefer it to Task.detached, which creates an unstructured task whose cancellation you have to manage yourself. Apple's documentation recommends avoiding detached tasks when structured concurrency can model the work.
  • DispatchQueue.main.async inside @MainActor code. You are already on the main actor. The dispatch only delays the work to a later run-loop turn and hides the actor from the compiler.
  • Turning on default main-actor isolation everywhere. It is designed for app and UI code. In a networking package it pushes work onto the main actor that never belonged there.

Checklist

  1. UI types are @MainActor. In app targets, consider Default Actor Isolation = MainActor (Swift 6.2+).
  2. Enable NonisolatedNonsendingByDefault (or Xcode's Approachable Concurrency) and mark CPU-heavy functions @concurrent.
  3. Replace DispatchQueue.main.async with await on main-actor methods. Use Task { @MainActor in } only at callback boundaries.
  4. Drop reflexive [weak self] in short tasks. Keep it, with cancellation, for long-lived loops.
  5. In SwiftUI, prefer .task {} and .task(id:) (iOS 15+) to manually stored tasks.

The GCD-era rules were right for their time. With Swift 6.2, the compiler can enforce most of them for you, but only if you tell it where your code is supposed to run.

tekinali.db · read-only · ● connected

SQLite 3.53.3 · 8 tables, 2 views.

Click a table on the left, pick a query below or write your own. Help: \?

↑↓ select · Enter open · Esc close