Skip to content

How to manage strong & weak references with Async Await | Modern Swift Concurrency #13

When a Task captures self, the task can keep the view model alive even after the user has navigated away — but blindly using [weak self] everywhere creates a different problem: your task silently does nothing when the object it was working for has already gone. Understanding when to use strong vs. weak references — and when to sidestep the question entirely by managing the Task directly — is essential for writing correct async/await code.

What You'll Learn

  • How Task { } captures self and why Swift's async/await does not automatically eliminate retain cycles
  • The difference between a strong capture, [self], and [weak self] in a Task closure
  • Why managing the Task reference directly is often cleaner than choosing a capture strategy
  • How storing and cancelling tasks on onDisappear or deinit prevents stale updates and memory leaks
  • When async functions on a type eliminate the capture question entirely

Mental Model

Imagine you hire a contractor to renovate your kitchen (a Task). If you give the contractor your house keys (strong reference to self), the contractor will keep your house from being demolished until the job is done — even if you move out. That may be exactly what you want for a short job. But for a long renovation, you might not want the house held open indefinitely.

With [weak self], you give the contractor a note with your phone number instead of the keys. When the job finishes, the contractor calls you — but if you have already moved out and your number is disconnected (self is nil), the contractor just shrugs and goes home without doing anything.

The cleanest approach is often to keep the work order itself (Task reference) and call the contractor's dispatch office directly when you want to cancel. You never need to choose keys vs. phone number — you just cancel the job.

Detailed Explanation

Every Task { } closure that references self captures a reference to the object that created it. For class types, this is a strong reference by default — the task's closure holds a retain count on self, keeping it alive until the task finishes. Unlike traditional closure-based APIs (like URLSession completion handlers or Combine's sink), Task closures in Swift are not automatically weak.

This matters most in view models. A SwiftUI @StateObject view model is owned by the view, and when the view disappears its reference count should drop to zero, deallocating the model. But if a Task is still running inside that model and the task holds a strong reference to the model, the model is kept alive. The task eventually finishes, updates state on a deallocated-but-retained object, and the UI is either stale or the model leaks until the task resolves.

[weak self] inside a Task closure solves the memory leak — the task will not keep self alive. But it introduces a different concern: after the suspension point (await), self may be nil. You must either guard-unwrap and skip the update, or use optional chaining. If the task's purpose was to update the view model's published state, silently doing nothing after await may be acceptable — the view is gone anyway. But if the task is performing work with observable side effects (writing to a database, uploading a file), silently abandoning it mid-operation could leave the system in an inconsistent state.

The most pragmatic pattern for view models is to manage the Task instance explicitly. Store it as a property, cancel it when the view disappears, and capture self strongly — the task's lifetime is now bounded by the cancellation call, so the strong reference cannot leak. This is demonstrated by updateData5 and updateData6, which store Task references and cancel them from cancelTasks().

The cleanest option of all — when practical — is updateData8: make the function async and let the call site (SwiftUI's .task modifier) own the task lifecycle. The .task modifier automatically cancels its task when the view disappears, so there is no Task to manage and no capture strategy to choose.

Code Structure

StrongSelfBootcamp.swift contains eight variants of the same fetch operation, each demonstrating a different capture approach. Reading them in sequence from updateData through updateData8 is the lesson: each version addresses a limitation of the previous one, ending with the two cleanest approaches — explicit task management and async methods tied to SwiftUI's .task modifier.

Complete Code

StrongSelfBootcamp.swift

swift
import SwiftUI

// Simple data service — `final` prevents subclassing, `async` marks the suspension point.
final class StrongSelfDataService {
    
    // Simulates a network call; `async` means callers must `await` it.
    func getData() async -> String {
        "Updated data!"
    }
    
}

final class StrongSelfBootcampViewModel: ObservableObject {
    
    @Published var data: String = "Some title!"
    let dataService = StrongSelfDataService()
    
    // A single stored task — cancelling it stops one specific ongoing operation.
    private var someTask: Task<Void, Never>? = nil
    // An array of stored tasks — when multiple concurrent operations can run at once.
    private var myTasks: [Task<Void, Never>] = []

    // Cancels and clears all stored tasks — call this from onDisappear or deinit.
    func cancelTasks() {
        someTask?.cancel()
        someTask = nil
        
        myTasks.forEach({ $0.cancel() })
        myTasks = []
    }
    
    // This implies a strong reference...
    // Swift captures `self` implicitly in a Task closure — equivalent to [self] in.
    func updateData() {
        Task {
            data = await dataService.getData() // implicit strong capture of self
        }
    }
    
    // This is a strong reference...
    // Explicit `self.` makes the strong capture visible in code review.
    func updateData2() {
        Task {
            self.data = await self.dataService.getData() // still a strong capture
        }
    }
    
    // This is a strong reference...
    // [self] in the capture list is explicit but semantically identical to the default.
    func updateData3() {
        Task { [self] in
            self.data = await self.dataService.getData()
        }
    }
    
    // This is a weak reference
    // After `await`, `self` might be nil if the view model was deallocated during the suspension.
    func updateData4() {
        Task { [weak self] in
            // Guard-let safely unwraps; if self is nil, we skip the update entirely.
            if let data = await self?.dataService.getData() {
                self?.data = data // optional chain: a no-op if self became nil
            }
        }
    }
    
    // We don't need to manage weak/strong
    // We can manage the Task!
    // Storing the task and cancelling it bounds its lifetime — strong capture is safe.
    func updateData5() {
        someTask = Task {
            self.data = await self.dataService.getData() // strong capture is fine: task is bounded
        }
    }
    
    // We can manage the Task!
    // Collecting tasks in an array lets you cancel all of them at once from cancelTasks().
    func updateData6() {
        let task1 = Task {
            self.data = await self.dataService.getData()
        }
        myTasks.append(task1)
        
        let task2 = Task {
            self.data = await self.dataService.getData()
        }
        myTasks.append(task2)
    }
    
    // We purposely do not cancel tasks to keep strong references
    // Task.detached breaks out of the structured concurrency tree entirely —
    // it inherits no actor isolation and no task-local values from the caller.
    func updateData7() {
        Task {
            self.data = await self.dataService.getData()
        }
        Task.detached {
            // This runs on an unspecified thread outside the current actor's isolation.
            self.data = await self.dataService.getData()
        }
    }
    
    // The cleanest approach: make the function async.
    // The caller (SwiftUI's .task modifier) owns the task lifetime and cancels it automatically.
    func updateData8() async {
        self.data = await self.dataService.getData() // no Task created here — no capture decision needed
    }

}

struct StrongSelfBootcamp: View {
    
    @StateObject private var viewModel = StrongSelfBootcampViewModel()
    
    var body: some View {
        Text(viewModel.data)
            .onAppear {
                // updateData() creates an untracked Task — will keep viewModel alive until it finishes.
                viewModel.updateData()
            }
            .onDisappear {
                // Cancels all stored tasks, allowing viewModel to deallocate once the view is gone.
                viewModel.cancelTasks()
            }
            // .task's Task is owned by SwiftUI and cancelled automatically on view disappear.
            .task {
                await viewModel.updateData8()
            }
    }
}

struct StrongSelfBootcamp_Previews: PreviewProvider {
    static var previews: some View {
        StrongSelfBootcamp()
    }
}

Code Walkthrough

  1. updateData / updateData2 / updateData3 — Strong captures — All three are equivalent: the Task holds a strong reference to the view model. If the view disappears while the task is running, the view model is kept alive until the task finishes. For short-lived tasks (< 1 second), this is rarely a problem. For tasks doing network requests or long operations, this is a memory leak with stale state updates.

  2. updateData4 — Weak capture[weak self] means the task does not retain the view model. After await, self might be nil if the view model was deallocated during the suspension. The if let guard prevents a crash, but the update is silently dropped. This is correct when the update was only meaningful for the current screen — there is no point writing to a view model that no longer has a view.

  3. updateData5 / updateData6 — Task management — Storing the Task and cancelling it on onDisappear (via cancelTasks()) is the most explicit and controllable approach. The strong capture is fine because the task's lifetime is bounded: once cancelled, the task completes at its next cancellation check and releases its captures. No stale updates, no leaked memory beyond the task's duration.

  4. updateData7Task.detached — A detached task does not inherit the caller's actor isolation or task-local values. This means the assignment self.data = ... happens from an arbitrary thread, which is a data race if StrongSelfBootcampViewModel is not actor-isolated. Avoid Task.detached unless you specifically need to escape the current actor's isolation domain.

  5. updateData8 — Async function — Making the function async eliminates the capture question entirely. The caller creates the Task, owns it, and decides when to cancel. SwiftUI's .task modifier does this automatically — when the view disappears, SwiftUI cancels the task, which cancels updateData8 at its next await suspension point.

Common Mistakes

Mistake: Assuming async/await automatically prevents retain cycles.
Unlike Combine's sink — where [weak self] is nearly always the right answer — Task closures are not automatically weakified. Swift's structured concurrency gives you tools to manage this (task cancellation, task lifetimes), but it does not change reference counting. Every Task { } with an implicit or explicit self capture creates a strong retain unless you specify [weak self].

Mistake: Using [weak self] everywhere without considering whether silence is acceptable.
[weak self] means "if I'm deallocated during this async operation, just do nothing." That is correct for UI updates (no view, no point). It is wrong for writes to a database, analytics events, or file uploads where the operation should complete regardless of whether the initiating view model is still alive. For those cases, use a strong capture or manage the operation at a higher scope (a service singleton, a detached task with a longer lifecycle).

Mistake: Leaving tasks running on onDisappear without cancellation.
If a view model is pushed onto a navigation stack, then popped, and an in-flight Task holds a strong reference to the view model, the model is leaked for the duration of the task. Multiply this by every navigation event and every background fetch, and you have a growing heap of zombie view models. Always call cancelTasks() (or equivalent) from onDisappear for any task that should not outlive the view.

Key Takeaways

  • Task { } captures self strongly by default — unlike Combine's .sink, Swift does not automatically weaken the capture.
  • The cleanest approach is to either make your function async (letting the call site's .task manage lifetime) or to store the Task and cancel it on onDisappear.
  • Use [weak self] only when a silent no-op after the suspension point is semantically correct; for side-effecting operations that must complete, keep a strong reference and bound the task's lifetime explicitly.

Last updated: June 27, 2026

Released under the MIT License.