A Task {} in viewDidLoad keeps running after the screen is popped and leaks self
The controller below starts a polling loop in viewDidLoad. After the screen is popped, the loop keeps running and Instruments shows the controller is never deallocated.
Constraint: keep the polling behavior; fix both the lifetime and the memory problem.
final class FeedViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
Task {
while true {
self.feed = try await self.api.poll()
try await Task.sleep(for: .seconds(5))
}
}
}
}
Find and fix both problems.
Two bugs. An unstructured Task {} is not tied to the controller — popping never cancels it, so it runs on. And it captures self strongly, blocking deinit. Fix — store the task, cancel() on disappear, capture [weak self].
- ✗Thinking an unstructured
Task {}is cancelled on deinit - ✗Believing
Task {}is awaited like a structured child task - ✗Expecting
Task.detachedto fix lifetime and capture at once
- →Why does a strong
selfcapture keep the controller alive? - →Where should the stored task be cancelled in a UIKit lifecycle?
The task is not tied to the screen, so it must be stored and cancelled yourself; [weak self] breaks the strong reference:
final class FeedViewController: UIViewController {
private var pollTask: Task<Void, Never>?
override func viewDidLoad() {
super.viewDidLoad()
pollTask = Task { [weak self] in
while let self, !Task.isCancelled {
self.feed = try? await self.api.poll()
try? await Task.sleep(for: .seconds(5))
}
}
}
deinit { pollTask?.cancel() }
}
Storing the task lets you cancel() it (unstructured tasks are never cancelled for you). [weak self] lets the controller deinit, and once self is nil the loop exits. The Task.isCancelled check makes the cooperative cancel actually stop the loop, and Task.sleep throws on cancel, so a pending sleep unwinds immediately.