How to avoid retain cycles when working with tasks in Swift
Understanding memory management in Swift concurrency.
08 Dec 2025 · 4 min read
Swift's concurrency system gives us a clean, readable way to express asynchronous work using async/await and tasks. Since we are capturing values when working with tasks, it's important to understand how memory management works here to avoid potencial retain cycles.
In this article, we look at how tasks hold on to references and where retain cycles might appear.

Whenever we create a task, for example through Task {}, Swift captures any values we reference inside the task body. These captures are strong by default.
This means:
- A task keeps its captured values alive until the task completes.
- If we capture self strongly, and self also holds a reference to the task, we create a retain cycle.
- But importantly, this cycle lasts only for the lifetime of the task.
Let's look at a concrete example:
class ImageLoader {private var task: Task<Void, Never>?func load() {task = Task {await loadImage()}}func loadImage() async {// ...}}
What happens here is:
- the ImageLoader instance strongly retains the task (because task is a stored property)
- the task strongly retains its closure,
- the closure strongly retains self (because we call downloadImages())
So, while the task is running, we do have a retain cycle:
ImageLoader → Task → ImageLoader
However, tasks have an important difference from classic stored completion handlers: a task releases its closure once the task finishes.
This means:
- In this example, the cycle lasts only for as long as the image is loading.
- When the task completes, it drops its closure and therefore releases self.
If we prefer to avoid even this temporary cycle, we can capture self weakly with [weak self] but for short-lived tasks it is usually optional.
Where retain cycles become memory leaks
Temporary retain cycles are rarely an issue, but with permanent ones we may run into memory leaks. This happens when all three are true:
- We store the task (as a property).
- The task's closure captures self strongly.
- The task never finishes or finishes much later than we expect.
This can appear in long-running loops or stream processing:
class MessageListener {private var task: Task<Void, Never>?func start() {task = Task { [weak self] infor await message in await messages.stream() {guard let self else { return }self.handle(message)}}}func handle(_ message: Message) { }}
In this example, the loop may run indefinitely. Without [weak self], the task keeps self alive, and self keeps the task alive, forming a classic retain cycle.
Using a weak capture breaks this cycle and ensures that the task naturally terminates once the listener goes out of scope.



Newsletter
Like to support my work?
Say hi
Related tags
Articles with related topics
Latest articles and tips



