Memory Management
Use this when:
- A task or async sequence is keeping objects alive longer than expected.
- You suspect a retain cycle between a task and its owner.
- You need to verify deallocation behavior or use
isolated deinit.
Skip this file if:
- You mainly need to protect mutable state from races. Use
actors.md. - You are debugging slow async code. Use
performance.md.
Jump to:
- Core Concepts (Task Capture)
- Retain Cycles
- One-Way Retention
- Async Sequences and Retention
- Isolated Deinit (Swift 6.2+)
- Detection and Testing
- Common Patterns
Core Concepts
Tasks capture like closures
Tasks capture variables and references just like regular closures. Swift doesn't automatically prevent retain cycles in concurrent code.
Task {
self.doWork() // ⚠️ Strong capture of self
}Why concurrency hides memory issues
- Tasks may live longer than expected
- Async operations delay execution
- Harder to track when memory should be released
- Long-running tasks can hold references indefinitely
Course Deep Dive: This topic is covered in detail in Lesson 8.1: Overview of memory management in Swift Concurrency
Retain Cycles
What is a retain cycle?
Two or more objects hold strong references to each other, preventing deallocation.
class A {
var b: B?
}
class B {
var a: A?
}
let a = A()
let b = B()
a.b = b
b.a = a // Retain cycle - neither can be deallocatedRetain cycles with Tasks
When task captures self strongly and self owns the task:
@MainActor
final class ImageLoader {
var task: Task<Void, Never>?
func startPolling() {
task = Task {
while true {
self.pollImages() // ⚠️ Strong capture
try? await Task.sleep(for: .seconds(1))
}
}
}
}
var loader: ImageLoader? = .init()
loader?.startPolling()
loader = nil // ⚠️ Loader never deallocated - retain cycle!Problem: Task holds self, self holds task → neither released.
Breaking Retain Cycles
Use weak self
func startPolling() {
task = Task { [weak self] in
while let self = self {
self.pollImages()
try? await Task.sleep(for: .seconds(1))
}
}
}
var loader: ImageLoader? = .init()
loader?.startPolling()
loader = nil // ✅ Loader deallocated, task stopsPattern for long-running tasks
task = Task { [weak self] in
while let self = self {
await self.doWork()
try? await Task.sleep(for: interval)
}
}Course Deep Dive: This topic is covered in detail in Lesson 8.2: Preventing retain cycles when using Tasks
Loop exits when self becomes nil.
One-Way Retention
Task retains self, but self doesn't retain task. Object stays alive until task completes.
@MainActor
final class ViewModel {
func fetchData() {
Task {
await performRequest()
updateUI() // ⚠️ Strong capture
}
}
}
var viewModel: ViewModel? = .init()
viewModel?.fetchData()
viewModel = nil // ViewModel stays alive until task completesExecution order:
- Task starts
viewModel = nil(but object not deallocated)- Task completes
- ViewModel finally deallocated
When one-way retention is acceptable
Short-lived tasks that complete quickly:
func saveData() {
Task {
await database.save(self.data) // OK - completes quickly
}
}When to use weak self
Long-running or indefinite tasks:
func startMonitoring() {
Task { [weak self] in
for await event in eventStream {
self?.handle(event)
}
}
}Async Sequences and Retention
Problem: Infinite sequences
@MainActor
final class AppLifecycleViewModel {
private(set) var isActive = false
private var task: Task<Void, Never>?
func startObserving() {
task = Task {
for await _ in NotificationCenter.default.notifications(
named: .didBecomeActive
) {
isActive = true // ⚠️ Strong capture, never ends
}
}
}
}
var viewModel: AppLifecycleViewModel? = .init()
viewModel?.startObserving()
viewModel = nil // ⚠️ Never deallocated - sequence continuesProblem: Async sequence never finishes, task holds self indefinitely.
Solution 1: Manual cancellation
func startObserving() {
task = Task {
for await _ in NotificationCenter.default.notifications(
named: .didBecomeActive
) {
isActive = true
}
}
}
func stopObserving() {
task?.cancel()
}
// Usage
viewModel?.startObserving()
viewModel?.stopObserving() // Must call before release
viewModel = nilSolution 2: Weak self with guard
func startObserving() {
task = Task { [weak self] in
for await _ in NotificationCenter.default.notifications(
named: .didBecomeActive
) {
guard let self = self else { return }
self.isActive = true
}
}
}Task exits when self deallocates.
Isolated deinit (Swift 6.2+)
Clean up actor-isolated state in deinit:
@MainActor
final class ViewModel {
private var task: Task<Void, Never>?
isolated deinit {
task?.cancel()
}
}Limitation: Won't break retain cycles (deinit never called if cycle exists).
Use for: Cleanup when object is being deallocated normally.
Common Patterns
Short-lived task (strong capture OK)
func saveData() {
Task {
await database.save(self.data)
self.updateUI()
}
}When safe: Task completes quickly, acceptable for object to live until done.
Long-running task (weak self required)
func startPolling() {
task = Task { [weak self] in
while let self = self {
await self.fetchUpdates()
try? await Task.sleep(for: .seconds(5))
}
}
}Async sequence monitoring (weak self + guard)
func startMonitoring() {
task = Task { [weak self] in
for await event in eventStream {
guard let self = self else { return }
self.handle(event)
}
}
}Cancellable work with cleanup
func startWork() {
task = Task { [weak self] in
defer { self?.cleanup() }
while let self = self {
await self.doWork()
try? await Task.sleep(for: .seconds(1))
}
}
}Detection Strategies
Add deinit logging
deinit {
print("✅ \(type(of: self)) deallocated")
}If deinit never prints → likely retain cycle.
Memory graph debugger
- Run app in Xcode
- Debug → Debug Memory Graph
- Look for cycles in object graph
Instruments
Use Leaks instrument to detect retain cycles at runtime.
Decision Tree
Task captures self?
├─ Task completes quickly?
│ └─ Strong capture OK
│
├─ Long-running or infinite?
│ ├─ Can use weak self? → Use [weak self]
│ ├─ Need manual control? → Store task, cancel explicitly
│ └─ Async sequence? → [weak self] + guard
│
└─ Self owns task?
├─ Yes → High risk of retain cycle
└─ No → Lower risk, but check lifetimeBest Practices
- Default to weak self for long-running tasks
- Use guard let self in async sequences
- Cancel tasks explicitly when possible
- Add deinit logging during development
- Test object deallocation in unit tests
- Use Memory Graph to verify no cycles
- Document lifetime expectations in comments
- Prefer cancellation over weak self when possible
- Avoid nested strong captures in task closures
- Use isolated deinit for cleanup (Swift 6.2+)
Testing for Leaks
Unit test pattern
func testViewModelDeallocates() async {
var viewModel: ViewModel? = ViewModel()
weak var weakViewModel = viewModel
viewModel?.startWork()
viewModel = nil
// Give tasks time to complete
try? await Task.sleep(for: .milliseconds(100))
XCTAssertNil(weakViewModel, "ViewModel should be deallocated")
}SwiftUI view test
func testViewDeallocates() {
var view: MyView? = MyView()
weak var weakView = view
view = nil
XCTAssertNil(weakView)
}Common Mistakes
❌ Forgetting weak self in loops
Task {
while true {
self.poll() // Retain cycle
try? await Task.sleep(for: .seconds(1))
}
}❌ Strong capture in async sequences
Task {
for await item in stream {
self.process(item) // May never release
}
}❌ Not canceling stored tasks
class Manager {
var task: Task<Void, Never>?
func start() {
task = Task {
await self.work() // Retain cycle
}
}
// Missing: deinit { task?.cancel() }
}❌ Assuming deinit breaks cycles
deinit {
task?.cancel() // Never called if retain cycle exists
}Examples by Use Case
Polling service
final class PollingService {
private var task: Task<Void, Never>?
func start() {
task = Task { [weak self] in
while let self = self {
await self.poll()
try? await Task.sleep(for: .seconds(5))
}
}
}
func stop() {
task?.cancel()
}
}Notification observer
@MainActor
final class NotificationObserver {
private var task: Task<Void, Never>?
func startObserving() {
task = Task { [weak self] in
for await notification in NotificationCenter.default.notifications(
named: .someNotification
) {
guard let self = self else { return }
self.handle(notification)
}
}
}
isolated deinit {
task?.cancel()
}
}Download manager
final class DownloadManager {
private var tasks: [URL: Task<Data, Error>] = [:]
func download(_ url: URL) async throws -> Data {
let task = Task { [weak self] in
defer { self?.tasks.removeValue(forKey: url) }
return try await URLSession.shared.data(from: url).0
}
tasks[url] = task
return try await task.value
}
func cancelAll() {
tasks.values.forEach { $0.cancel() }
tasks.removeAll()
}
}Timer
actor Timer {
private var task: Task<Void, Never>?
func start(interval: Duration, action: @Sendable () async -> Void) {
task = Task {
while !Task.isCancelled {
await action()
try? await Task.sleep(for: interval)
}
}
}
func stop() {
task?.cancel()
}
}Common Mistakes Agents Make
- Forgetting
[weak self]in stored tasks: Whenselfowns the task and the task capturesself, a retain cycle prevents deallocation. - Strong capture in infinite
AsyncSequenceloops:for awaitover an infinite sequence with a strongselfcapture keeps the object alive forever. - Not cancelling stored tasks on cleanup: If the task outlives its owner, it retains captured objects indefinitely.
- Assuming
isolated deinitbreaks retain cycles:isolated deinitruns cleanup on the correct actor, but if a cycle preventsdeinitfrom being called at all, the cleanup never executes. - Using
try?in loops withTask.sleep:try?can swallowCancellationError, causing the loop to continue running after cancellation. Always checkTask.isCancelledexplicitly.
Debugging Checklist
When object won't deallocate:
- [ ] Check for strong self captures in tasks
- [ ] Verify tasks are canceled or complete
- [ ] Look for infinite loops or sequences
- [ ] Check if self owns the task
- [ ] Use Memory Graph to find cycles
- [ ] Add deinit logging to verify
- [ ] Test with weak references
- [ ] Review async sequence usage
- [ ] Check nested task captures
- [ ] Verify cleanup in deinit
Further Learning
For migration strategies, real-world examples, and advanced memory patterns, see Swift Concurrency Course.