Swift Memory Leaks: Find Retain Cycles on iPhone From Windows
Last updated
Swift memory leaks usually come from retain cycles. Swift manages memory with automatic reference counting (ARC), so when two objects strongly reference each other, or an object holds a closure that strongly references the object itself, neither is freed after the screen closes. To fix it, make one reference in the cycle weak or unowned, or break the cycle when the screen goes away by invalidating timers, removing observers and cancelling tasks.
This tutorial shows leaking and fixed code for the 7 most common leak scenarios, then explains how to confirm a leak on a real iPhone from Windows with KXBuild and kxdevice, find its source and verify the fix.
You need an iPhone and a USB cable, and a project that already builds with KXBuild and installs on the phone with F5. See the install page to set up, and Debug iOS apps on Windows with LLDB, breakpoints and crash logs for debugging basics. The code in this tutorial compiles in both the Swift 5 and Swift 6 language modes.
How do I tell whether an object was freed?
The most direct way to tell whether an object was freed is to check whether its deinit ran. Before you start investigating, add a print line to each screen and view model you suspect.
1deinit {
2 print("OrderDetailViewController deinit")
3}
Press F5, open the screen and go back. The line should appear in Output > KXBuild Console. If it doesn’t, another object still holds a strong reference to it, and the scenarios below cover nearly every cause.
Scenario 1: how does capturing self create a retain cycle?
The view controller holds the view model, the view model holds a callback closure, and the closure uses self. The three form a cycle.
Leaking code.
1final class SearchViewModel {
2 var onResults: (([String]) -> Void)?
3
4 func search(_ text: String) {
5 onResults?([text])
6 }
7}
8
9final class SearchViewController: UIViewController {
10 private let viewModel = SearchViewModel()
11 private let label = UILabel()
12
13 override func viewDidLoad() {
14 super.viewDidLoad()
15 viewModel.onResults = { results in
16 self.label.text = results.joined(separator: ", ")
17 }
18 }
19
20 deinit {
21 print("SearchViewController deinit")
22 }
23}
Fixed code. Write [weak self] in the capture list so the closure no longer holds the view controller. Unwrap with guard let self at the start of the closure, and return early if the screen is already gone.
1override func viewDidLoad() {
2 super.viewDidLoad()
3 viewModel.onResults = { [weak self] results in
4 guard let self else { return }
5 self.label.text = results.joined(separator: ", ")
6 }
7}
Not every closure needs [weak self]. Closures that run once and are thrown away, like UIView.animate, map and forEach, and one-shot network callbacks don’t form a cycle. At most they free the object a little later. The ones that need weak are closures another object keeps for a long time: callbacks stored in properties, timers, notifications, Combine subscriptions and tasks that keep running.
Scenario 2: a strong delegate
The view controller holds DownloadTask, and its delegate points back to the view controller.
Leaking code.
1@MainActor
2protocol DownloadTaskDelegate: AnyObject {
3 func downloadDidFinish(_ task: DownloadTask)
4}
5
6@MainActor
7final class DownloadTask {
8 var delegate: (any DownloadTaskDelegate)?
9
10 func finish() {
11 delegate?.downloadDidFinish(self)
12 }
13}
14
15final class DownloadViewController: UIViewController, DownloadTaskDelegate {
16 private let task = DownloadTask()
17
18 override func viewDidLoad() {
19 super.viewDidLoad()
20 task.delegate = self
21 }
22
23 func downloadDidFinish(_ task: DownloadTask) {
24 print("Download finished")
25 }
26}
Fixed code. Add weak to the delegate property. The protocol has to be declared AnyObject, otherwise weak is an error, because only class instances can be referenced weakly.
1@MainActor
2final class DownloadTask {
3 weak var delegate: (any DownloadTaskDelegate)?
4
5 func finish() {
6 delegate?.downloadDidFinish(self)
7 }
8}
The delegate and dataSource of system controls are already weak. The problem is always in classes you write yourself. In Objective-C, the matching property is @property (nonatomic, weak) id<XxxDelegate> delegate;.
Scenario 3: why does a Timer keep the screen alive?
A Timer created with target and selector keeps a strong reference to its target until you call invalidate(), as Apple’s Timer documentation states. The run loop holds the timer and the timer holds the screen, so the screen isn’t freed after it closes, and the timer keeps firing in the background.
Leaking code.
1final class CountdownViewController: UIViewController {
2 private var timer: Timer?
3 private var remaining = 60
4
5 override func viewDidLoad() {
6 super.viewDidLoad()
7 timer = Timer.scheduledTimer(timeInterval: 1, target: self,
8 selector: #selector(tick),
9 userInfo: nil, repeats: true)
10 }
11
12 @objc private func tick() {
13 remaining -= 1
14 }
15}
Fixed code. Switch to the block-based API with a weak self, and call invalidate() when the screen goes away. If the block finds that self is gone, it stops the timer too.
1final class CountdownViewController: UIViewController {
2 private var timer: Timer?
3 private var remaining = 60
4
5 override func viewDidAppear(_ animated: Bool) {
6 super.viewDidAppear(animated)
7 timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] timer in
8 guard let self else {
9 timer.invalidate()
10 return
11 }
12 MainActor.assumeIsolated {
13 self.tick()
14 }
15 }
16 }
17
18 override func viewWillDisappear(_ animated: Bool) {
19 super.viewWillDisappear(animated)
20 timer?.invalidate()
21 timer = nil
22 }
23
24 private func tick() {
25 remaining -= 1
26 }
27}
The block-based Timer callback is a @Sendable closure. A timer created on the main thread fires on the main thread, so MainActor.assumeIsolated tells the compiler the code runs on the main actor, which you need in Swift 6 mode to call the screen’s methods. CADisplayLink also holds its target strongly. Handle it the same way and call invalidate() when the screen goes away.
Scenario 4: NotificationCenter block observers
addObserver(forName:object:queue:using:) keeps a strong reference to the block until you remove the observer. If the block uses self, the screen stays alive along with it.
Leaking code. The return value isn’t stored, so there’s no way to remove the observer later.
1final class InboxViewController: UIViewController {
2 override func viewDidLoad() {
3 super.viewDidLoad()
4 NotificationCenter.default.addObserver(
5 forName: UIApplication.didBecomeActiveNotification,
6 object: nil,
7 queue: .main
8 ) { _ in
9 self.refresh()
10 }
11 }
12
13 func refresh() {}
14}
Fixed code. Capture self weakly in the block, and keep the returned observer in a small wrapper class. The wrapper is freed with the screen and removes the observer in its deinit.
1final class NotificationToken {
2 private let token: any NSObjectProtocol
3
4 init(_ token: any NSObjectProtocol) {
5 self.token = token
6 }
7
8 deinit {
9 NotificationCenter.default.removeObserver(token)
10 }
11}
12
13final class InboxViewController: UIViewController {
14 private var activeObservation: NotificationToken?
15
16 override func viewDidLoad() {
17 super.viewDidLoad()
18 let token = NotificationCenter.default.addObserver(
19 forName: UIApplication.didBecomeActiveNotification,
20 object: nil,
21 queue: .main
22 ) { [weak self] _ in
23 MainActor.assumeIsolated {
24 self?.refresh()
25 }
26 }
27 activeObservation = NotificationToken(token)
28 }
29
30 func refresh() {}
31}
The selector-based API is simpler. Apple’s documentation says that if your app targets iOS 9 or later, observers registered with addObserver(_:selector:name:object:) don’t need to be removed manually. The selector form has no block, so there’s nothing to capture self.
1override func viewDidLoad() {
2 super.viewDidLoad()
3 NotificationCenter.default.addObserver(
4 self,
5 selector: #selector(appDidBecomeActive),
6 name: UIApplication.didBecomeActiveNotification,
7 object: nil
8 )
9}
10
11@objc private func appDidBecomeActive() {
12 refresh()
13}
Scenario 5: Combine sink
The subscription sink returns is stored in cancellables, cancellables belongs to self, and the sink closure captures self.
Leaking code.
1@MainActor
2final class SearchModel: ObservableObject {
3 @Published var query = ""
4 @Published private(set) var results: [String] = []
5 private var cancellables = Set<AnyCancellable>()
6
7 init() {
8 $query
9 .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
10 .sink { text in
11 self.results = self.search(text)
12 }
13 .store(in: &cancellables)
14 }
15
16 private func search(_ text: String) -> [String] {
17 text.isEmpty ? [] : [text]
18 }
19}
Fixed code. Capture self weakly in the closure.
1init() {
2 $query
3 .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
4 .sink { [weak self] text in
5 guard let self else { return }
6 self.results = self.search(text)
7 }
8 .store(in: &cancellables)
9}
If the result only goes into another @Published property, use assign(to: &$results). The subscription lives as long as the property, no cycle forms, and you don’t even need cancellables. assign(to: \.results, on: self) holds self strongly and leaks too, so don’t use that form.
1init() {
2 $query
3 .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
4 .map { text in text.isEmpty ? [] : [text] }
5 .assign(to: &$results)
6}
Scenario 6: a Task captures self
A Task holds the self it captures strongly until the task finishes. A normal one-shot request ends quickly, so that’s mostly harmless. A loop that never ends, such as listening for messages or polling, keeps the screen alive forever.
Leaking code.
1final class ChatViewController: UIViewController {
2 private var messages: [String] = []
3
4 override func viewDidLoad() {
5 super.viewDidLoad()
6 Task {
7 for await message in ChatService.messages() {
8 self.messages.append(message)
9 }
10 }
11 }
12}
Fixed code. Keep the task and cancel it when the screen goes away. Also capture self weakly and unwrap it each time a message arrives, so the screen isn’t held while the task waits.
1final class ChatViewController: UIViewController {
2 private var messages: [String] = []
3 private var listenTask: Task<Void, Never>?
4
5 override func viewDidAppear(_ animated: Bool) {
6 super.viewDidAppear(animated)
7 listenTask = Task { [weak self] in
8 for await message in ChatService.messages() {
9 guard let self else { return }
10 self.messages.append(message)
11 }
12 }
13 }
14
15 override func viewWillDisappear(_ animated: Bool) {
16 super.viewWillDisappear(animated)
17 listenTask?.cancel()
18 listenTask = nil
19 }
20
21 deinit {
22 print("ChatViewController deinit")
23 }
24}
ChatService in this example is a mock source that produces one message per second, and it stops when the task is cancelled.
1enum ChatService {
2 static func messages() -> AsyncStream<String> {
3 AsyncStream { continuation in
4 let task = Task {
5 var n = 0
6 while !Task.isCancelled {
7 try? await Task.sleep(nanoseconds: 1_000_000_000)
8 n += 1
9 continuation.yield("Message \(n)")
10 }
11 continuation.finish()
12 }
13 continuation.onTermination = { _ in task.cancel() }
14 }
15 }
16}
Put guard let self inside the loop, not before for await. If you put it before the loop, the reference turns strong again for the whole loop.
Scenario 7: class state objects in SwiftUI
A SwiftUI View is a struct. Closures in body capture the struct’s value, which can’t form a retain cycle, so you don’t need [weak self]. What does leak is a class you store with @State or @StateObject, and the problem is the same as before: timers, subscriptions and tasks inside it.
Leaking code. The model starts a timer in init, the timer holds the model strongly, and both are still alive after the screen closes.
1@Observable
2final class StopwatchModel {
3 var seconds = 0
4 private var timer: Timer?
5
6 init() {
7 timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
8 self.seconds += 1
9 }
10 }
11
12 deinit {
13 print("StopwatchModel deinit")
14 }
15}
16
17struct StopwatchView: View {
18 @State private var model = StopwatchModel()
19
20 var body: some View {
21 Text("\(model.seconds) s")
22 }
23}
Fixed code. Turn the loop into an async function and start it with the .task modifier. When the screen goes away, SwiftUI cancels the task automatically, Task.sleep throws a cancellation error, the loop exits and the model is freed with the screen.
1@MainActor
2@Observable
3final class StopwatchModel {
4 var seconds = 0
5
6 func run() async {
7 while !Task.isCancelled {
8 try? await Task.sleep(nanoseconds: 1_000_000_000)
9 seconds += 1
10 }
11 }
12
13 deinit {
14 print("StopwatchModel deinit")
15 }
16}
17
18struct StopwatchView: View {
19 @State private var model = StopwatchModel()
20
21 var body: some View {
22 Text("\(model.seconds) s")
23 .task {
24 await model.run()
25 }
26 }
27}
@Observable needs iOS 17. To support earlier versions, use ObservableObject with @StateObject and do the same thing. While you’re at it, check two other common SwiftUI problems that look like leaks.
- A model kept by a global variable or singleton. For example, it goes into a static dictionary used as a cache and is still referenced after the screen closes. Give the cache a size limit, or use
NSCache. - A model created with
@ObservedObject var model = Model(). The parent view creates a new one every time it refreshes and discards the old one, so state resets unexpectedly. Use@StateObjector@Statefor models a view creates itself, and@ObservedObjectonly for models passed in from outside.
How do I fix each of the seven retain cycles?
| Scenario | How the cycle forms | Fix |
|---|---|---|
| Callback closure stored in a property | Object → closure → self | [weak self] |
| Your own delegate | Child object → delegate → the screen that owns it | weak var delegate, add AnyObject to the protocol |
| target/selector Timer, CADisplayLink | Run loop → Timer → target | Block-based API with [weak self], invalidate() when the screen goes away |
| NotificationCenter block observer | Notification center → block → self | [weak self], keep and remove the return value, or use the selector form |
Combine sink |
self → cancellables → closure → self | [weak self], or assign(to: &$property) |
| Task loop that never ends | Task → self, and the task never finishes | Keep the task and cancel() it, guard let self inside the loop |
| Timers or subscriptions inside a SwiftUI state object | Model → timer or subscription → model | Start the loop with .task so it’s cancelled with the screen |
Should I use weak or unowned?
Neither one increases the reference count. They differ in what happens after the object is freed.
weak |
unowned |
|
|---|---|---|
| Type | Optional, declared with var |
Non-optional |
| After the object is freed | Becomes nil automatically |
The reference remains, and accessing it crashes the app |
| Before use | Unwrap it with self?.xxx or guard let self |
Use it directly |
| Good for | The other object may be freed first, such as delegates, screen callbacks, timers and notifications | The other object always lives at least as long, such as a credit card and its customer |
| When it goes wrong | Nothing happens, which can look like “the button does nothing” | A crash. In Debug the debugger stops on the line that accessed it |
The Swift book puts it this way. Use weak when the other instance has a shorter lifetime, and unowned when it has the same or a longer lifetime. See The Swift Programming Language: Automatic Reference Counting. Here’s a typical use of unowned. A credit card can never outlive its customer.
1final class Customer {
2 let name: String
3 var card: CreditCard?
4
5 init(name: String) {
6 self.name = name
7 }
8
9 deinit {
10 print("\(name) deinit")
11 }
12}
13
14final class CreditCard {
15 let number: String
16 unowned let customer: Customer
17
18 init(number: String, customer: Customer) {
19 self.number = number
20 self.customer = customer
21 }
22}
The typical case for [unowned self] in a closure is when only self holds the closure, such as a closure in a lazy property.
1final class PriceFormatter {
2 let prefix = "$"
3
4 lazy var format: (Double) -> String = { [unowned self] value in
5 "\(self.prefix)\(value)"
6 }
7}
When in doubt, use weak. If you use unowned in screen callbacks, network requests or timers, a screen that closes before a late callback arrives means a crash in production.
How do I find Swift memory leaks on Windows?
The methods below go from lightweight to thorough. Combine them as needed.
Confirm release with deinit and logs
While debugging, check Output > KXBuild Console for the deinit print. See Run and debug. If you don’t want to count prints every time you enter and leave a screen, add a check in the Debug configuration that prints a warning when an object is still alive two seconds after its screen closes.
1#if DEBUG
2enum LeakCheck {
3 @MainActor
4 static func expectRelease(_ object: AnyObject, after seconds: UInt64 = 2) {
5 let name = String(describing: type(of: object))
6 Task { [weak object] in
7 try? await Task.sleep(nanoseconds: seconds * 1_000_000_000)
8 if object != nil {
9 print("[LeakCheck] \(name) still alive \(seconds) s after closing, possible leak")
10 }
11 }
12 }
13}
14#endif
15
16final class DetailViewController: UIViewController {
17 override func viewDidDisappear(_ animated: Bool) {
18 super.viewDidDisappear(animated)
19 #if DEBUG
20 if isMovingFromParent || isBeingDismissed {
21 LeakCheck.expectRelease(self)
22 }
23 #endif
24 }
25}
isMovingFromParent covers going back in a navigation stack, and isBeingDismissed covers dismissing a modal. If the screen is embedded in another container, adjust the condition to match how you navigate.
When the debugger isn’t attached, or you need to test a Release build, log with Logger instead and read the phone’s log with kxdevice.
1import os
2
3private let memoryLog = Logger(subsystem: "com.example.myapp", category: "memory")
4
5final class OrderDetailViewController: UIViewController {
6 private let orderID: String
7
8 init(orderID: String) {
9 self.orderID = orderID
10 super.init(nibName: nil, bundle: nil)
11 memoryLog.notice("OrderDetailViewController init \(orderID, privacy: .public)")
12 }
13
14 required init?(coder: NSCoder) {
15 fatalError("init(coder:) has not been implemented")
16 }
17
18 deinit {
19 memoryLog.notice("OrderDetailViewController deinit")
20 }
21}
1kxdevice ostrace --subsystem com.example.myapp --match OrderDetailViewController
Enter and leave the screen a few times. init and deinit should come in pairs. If you only see init and no deinit, the screen isn’t being freed. For other ostrace filters, see the kxdevice command reference.
Find the closure that’s still alive with breakpoints and logpoints
Once you know which screen isn’t freed, find which reference keeps it. The fastest way is to see what code is still running after the screen closes.
- Logpoints. Right-click the first line of a suspect closure, choose Add Logpoint… and enter
Closure still running {self}. If the Debug Console keeps printing after you go back, that closure (a timer, notification, subscription or task) is still alive, and so is everything it holds. Logpoints don’t change your code and don’t need a rebuild. - Breakpoints. Set ordinary breakpoints in timer and notification callbacks. If they still hit after the screen closes, the Call Stack panel shows what triggered them.
- Debug Console. While stopped at a breakpoint, type
po selfto see which instance it is. If the address matches the one printed when you entered the screen earlier, the old screen wasn’t freed.
For breakpoints, logpoints and the Debug Console, see Debug iOS apps on Windows with LLDB, breakpoints and crash logs.
Check allocations and the memory graph with kxdevice
When you don’t know which screen leaks, or you want data that proves the fix works, record memory allocations with kxdevice. Launch the app first to get its PID. If the app is already running, look it up with apps ps.
1kxdevice apps launch com.example.myapp
2kxdevice apps ps --apps
Then record allocations with backtraces, and enter and leave the same screen five or six times on the phone while it records.
1kxdevice instruments allocations --pid 1234 --backtrace -n 20
Allocations that only grow are your suspects, and their backtraces point to the code and class that created them. To see the overall trend, run kxdevice web, open http://localhost:8780 in your browser and select your app’s process on the Instruments page to watch the memory graph. If memory steps up every time you enter and leave and never comes back down when you exit, that screen is almost certainly leaking. See Performance profiling for these commands, and iOS performance profiling on Windows without Xcode Instruments for more ways to track down memory problems.
Walkthrough: how do I track down a leaking order screen?
Here’s a concrete example that ties the methods together. Tapping an order in the list opens a detail screen that refreshes the shipping status every 5 seconds. Testers report that the app gets slower after viewing a few dozen orders.
1. Reproduce and confirm
Add a deinit print to OrderDetailViewController and press F5 with the Debug configuration. Open the detail screen and go back, three times. KXBuild Console never prints deinit, which confirms the detail screen isn’t freed.
2. Measure
Get the PID in a terminal, record allocations, and open and leave the detail screen five times.
1kxdevice apps ps --apps
2kxdevice instruments allocations --pid 1234 --backtrace -n 20
The output shows one more set of OrderDetailViewController allocations every time you open the screen, and none go away. The memory graph in kxdevice web steps up with every visit, matching what testers reported.
3. Locate
Open the detail screen’s code and go through the long-lived references using the table above. Two suspects turn up.
1override func viewDidLoad() {
2 super.viewDidLoad()
3 refreshTimer = Timer.scheduledTimer(timeInterval: 5, target: self,
4 selector: #selector(refreshStatus),
5 userInfo: nil, repeats: true)
6 service.onUpdate = { status in
7 self.statusLabel.text = status
8 }
9}
Add a logpoint Refreshing shipping status {self} on the first line of refreshStatus. After you go back to the list, the Debug Console still prints every 5 seconds, from three different instances. Three old screens are each kept alive by their own timer, and all of them are still making network requests.
4. Fix
Fix both. Replace the timer with the block-based API and [weak self], and stop it when the screen goes away. The detail screen owns service, so its callback also needs a weak self.
1override func viewDidAppear(_ animated: Bool) {
2 super.viewDidAppear(animated)
3 refreshTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] timer in
4 guard let self else {
5 timer.invalidate()
6 return
7 }
8 MainActor.assumeIsolated {
9 self.refreshStatus()
10 }
11 }
12}
13
14override func viewWillDisappear(_ animated: Bool) {
15 super.viewWillDisappear(animated)
16 refreshTimer?.invalidate()
17 refreshTimer = nil
18}
19
20override func viewDidLoad() {
21 super.viewDidLoad()
22 service.onUpdate = { [weak self] status in
23 self?.statusLabel.text = status
24 }
25}
If you only fix the timer and not the callback, the screen still isn’t freed. One object can be kept alive by several cycles at once, so check deinit again after every change.
5. Verify
Stop debugging and press F5 again. Open and leave the detail screen three times. Every time you go back, OrderDetailViewController deinit prints, and the logpoint stops printing. Then install a Release build, launch it and record allocations with the same command, using the new PID that apps launch prints.
1kxbuild build .\MyApp.xcodeproj -c Release --install
2kxdevice apps launch com.example.myapp
3kxdevice instruments allocations --pid 1234 --backtrace -n 20
The leak is fixed only when allocations drop back after you leave the screen and the memory graph no longer climbs in steps. Finally, keep LeakCheck.expectRelease(self) in this screen, so if someone introduces a retain cycle that causes Swift memory leaks later, a Debug run warns you right away.
FAQ
When do I need [weak self] in a Swift closure?
When another object keeps the closure for a long time and that object is held, directly or indirectly, by self. Examples are callbacks stored in properties, timers, notification observers, Combine subscriptions and tasks that never finish. Closures that run right away, like UIView.animate and map, and one-shot request callbacks don’t form a retain cycle, so you can leave it out.
What’s the difference between weak and unowned?
Neither increases the reference count. weak is optional and becomes nil automatically when the object is freed. unowned is non-optional, and accessing it after the object is freed crashes the app. Use weak when the other object may be freed first, use unowned only when you’re sure it lives at least as long, and use weak when in doubt.
How do I find memory leaks on Windows without Xcode’s Memory Graph?
Print in the deinit of screens and view models, press F5, enter and leave the screen, and check Output > KXBuild Console for the print. Once you’ve confirmed something isn’t freed, add logpoints in suspect closures to find the callback that’s still running. For hard data, record allocations with kxdevice instruments allocations --pid <pid> --backtrace, or watch the memory graph in kxdevice web.
Do I need [weak self] in a SwiftUI View?
No. A View is a struct, and closures in body can’t form a retain cycle with it. What you need to check is whether a class stored with @State or @StateObject has timers, subscriptions or tasks that hold it strongly. Start long-running work with .task, which is cancelled automatically when the screen goes away.
Does a missing deinit print always mean a memory leak?
Not always. The object may just be freed late, for example because a network callback captured it and it’s only freed when the request finishes. Wait a few seconds and check again, or use a delayed check like LeakCheck. It’s a leak only if the object is never freed.
Related reading
- iOS performance profiling on Windows without Xcode Instruments
- Debug iOS apps on Windows with LLDB, breakpoints and crash logs
- Read iPhone device logs with Logger and os_log, then filter and export them
- Swift 6 strict concurrency errors and how to fix them
- Performance profiling
- kxdevice command reference