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:
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.- Where a non-isolated
asyncfunction 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@MainActorand simplyawaitit. - 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 selfat the top of the task. WritingTask { [weak self] in guard let self else { return }; for await ... }makesselfstrong 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 capturesselfstrongly 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
asyncmeans background. WithNonisolatedNonsendingByDefault, it explicitly does not. Use@concurrentfor CPU-heavy work. I prefer it toTask.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.asyncinside@MainActorcode. 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
- UI types are
@MainActor. In app targets, considerDefault Actor Isolation = MainActor(Swift 6.2+). - Enable
NonisolatedNonsendingByDefault(or Xcode's Approachable Concurrency) and mark CPU-heavy functions@concurrent. - Replace
DispatchQueue.main.asyncwithawaiton main-actor methods. UseTask { @MainActor in }only at callback boundaries. - Drop reflexive
[weak self]in short tasks. Keep it, with cancellation, for long-lived loops. - 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.
- ---
- baslik: "Main actor, @concurrent and [weak self]: concurrency habits for Swift 6.2"
- ozet: "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."
- tarih: 2026-09-09
- etiketler:
- - ios
- - swift
- - concurrency
- kategoriler:
- - ios
- dil: en
- ---
- 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:
- ```swift
- 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:
- ```swift
- 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
- // 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:
- ```swift
- 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:
- ```swift
- 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.