Swift concurrency makes writing asynchronous code safer and more structured. However, working with async code still requires you to be mindful of memory management to avoid retain cycles, memory leaks, and unexpected object lifetimes.
This lesson will introduce you to that concept and shows a few common examples, where we’ll dive deeper into details in the next lesson of this module.
How Memory Management Works in Swift Concurrency
Under the hood, Swift’s concurrency model uses lightweight tasks to run asynchronous work. Each Task can capture variables, closures, and references — just like in regular Swift closures.
Tasks capture variables like closures do, while actors manage their memory independently and serialize access to their properties. In other words, Swift does not magically prevent retain cycles in concurrent code. You still need to be aware of how references are captured.
Why Concurrency Introduces New Pitfalls
Concurrency can hide memory issues because tasks may live longer than expected. Captured self-references can keep entire objects alive, and async operations delay execution, which makes it harder to track when memory should be released.
Imagine the following view model:
@MainActor
final class ContentViewModel {
deinit {
print("Deinit!")
}
func fetchData() {
Task {
await performNetworkRequest()
updateUI() // ⚠️ Captures self strongly!
}
}
func updateUI() {
print("Update UI!")
}
func performNetworkRequest() async {
print("Perform network request")
/// Simulate a network request of 1 second.
try? await Task.sleep(for: .seconds(1))
print("Finished network request")
}
}
If we would call it as follows:
var viewModel: ContentViewModel? = .init()
viewModel?.fetchData()
viewModel = nil
print("Set viewModel to nil")
You will see the following print statements:
Set viewModel to nil
Perform network request
Finished network request
Update UI!
DEINIT!
In other words, even though we’ve set the view model to nil, it’s only being released after the Task { } has completed executing. This is because we hold a strong reference to self.
We could change the code as follows:
Task { [weak self] in
await self?.performNetworkRequest()
self?.updateUI()
}
But even in this case, if we already entered performNetworkRequest(), we would see the following print statements:
Perform network request
Set viewModel to nil
Finished network request
DEINIT!
It completes the network request on self and releases the object after returning from performing the network request. These are important concepts to understand when working with Swift Concurrency, as you might hold on to references longer than expected.
Summary
Memory management in Swift Concurrency is just as important as with regular code. Since tasks can easily run for a longer period of time, it’s easy to create long retentions for instances that you expected to be released. This could unexpectedly increase your app’s memory footprint, so it’s important to manage memory well.
In the next lesson, we’ll go over a few more examples to help you better understand the memory management you need to handle.