Browse Questions

Answer: Unstructured Task { ... } blocks retain captured objects for the entire duration of the asynchronous operation, which can lead to delayed deallocation or memory leaks if long-running tasks are not cancelled.

When you create an unstructured Task or Task.detached, the task closure captures referenced instances strongly by default. If the task performs a long network request, an infinite loop, or listens to an AsyncStream, the captured object (self) remains retained in memory until the task completes.

Best Practices for Swift Concurrency:

  1. Cancel Tasks on Lifecycle Teardown: Store the Task reference and call task.cancel() in deinit, viewWillDisappear, or SwiftUI .onDisappear.
  2. Use [weak self] in Long-Running Tasks: Prevent holding self in memory during extended background operations.
  3. Structured Concurrency: Prefer structured concurrency (async let, withTaskGroup, withThrowingTaskGroup) because child tasks automatically cancel when the parent scope exits.

Code Example:

class LiveStreamViewModel {
    private var streamTask: Task<Void, Never>?
    
    func startListening(to stream: AsyncStream<String>) {
        // ❌ Leaks self indefinitely if stream never terminates
        // streamTask = Task { for await item in stream { self.handle(item) } }
        
        // ✅ Safe: Use [weak self] and check for cancellation
        streamTask = Task { [weak self] in
            for await item in stream {
                guard !Task.isCancelled, let self else { break }
                self.handle(item)
            }
        }
    }
    
    func stop() {
        streamTask?.cancel()
        streamTask = nil
    }
    
    deinit {
        streamTask?.cancel()
    }
    
    private func handle(_ item: String) {}
}

Key Points:

  • Structured tasks tie child lifetimes to the enclosing lexical scope; unstructured Task outlives the scope unless explicitly cancelled
  • Always check Task.isCancelled inside long loops or after await suspension points