iOS memory leaks: causes, detection, and fixes under ARC

iOS Egypt

Automatic Reference Counting removes most manual memory management, but it doesn't remove leaks, it just changes where they hide. Most iOS memory leaks aren't ARC failures; they're strong reference cycles the compiler can't see, buried in closures, delegates, and observers.

Senior engineers who already know ownership qualifiers still lose hours to leaks Instruments could have caught in minutes. This guide walks through why leaks occur under ARC, how to catch them with Instruments and the Memory Graph Debugger, and how to fix them with weak and unowned references, backed by real generation-count evidence.

iOS memory leaks under ARC: the short answer

Automatic Reference Counting tracks strong references with a per-object counter, deallocating memory only when that count hits zero. A retain cycle breaks this: two objects hold strong references to each other, the counter never reaches zero, and both objects outlive their intended scope.

Apple introduced Automatic Reference Counting in iOS 5 (2011) to replace manual retain/release calls, according to Apple's ARC documentation. The compiler still can't reason about ownership across a closure capture list or a delegate property declared strong instead of weak.

In practice, most leaks trace back to closure captures, delegates, and observers left unbroken, not to ARC itself.

SwiftUI state objects and XIB-loaded view controllers reproduce the same failure mode. This piece covers where cycles hide, how to catch them in Instruments and the Memory Graph Debugger, and when to reach for weak versus unowned.

What is a memory leak in iOS?

A memory leak in iOS is memory Automatic Reference Counting can no longer reclaim, because a strong reference still points to an object nothing in the app actually needs anymore. That's different from a dangling pointer, which points to memory already freed and produces a zombie object or a crash on access.

Leaks don't crash immediately; they accumulate. A view controller stuck in a retain cycle with its closure, a delegate holding a strong reference back to its owner, a SwiftUI view capturing self in a Combine subscription, each keeps memory resident until iOS issues a low-memory warning, and eventually a watchdog termination kills the app outright.

According to Apple's Automatic Reference Counting documentation, unbroken strong reference cycles are the primary cause of memory leaks under ARC, since the reference counter for each object never reaches zero.

Why memory leaks matter: Battery, watchdog termination, UX

Memory leaks matter because iOS does not fail gracefully when they run unchecked. Every leaked object keeps allocated memory resident, and on a phone with a fixed memory budget that shows up first as battery drain from extra paging and background compaction, then as UI stutter as the app competes with the system for headroom.

The differentiator most guides skip is watchdog termination. iOS runs a background process, jetsam, that kills any app exceeding its memory ceiling without a crash log a developer recognizes: the process just disappears, and support tickets read "the app randomly closes." iOS jetsam limits vary by device: roughly 900MB on an iPhone SE 2nd gen (3GB RAM) up to roughly 4000MB on an iPhone 16 Pro (8GB RAM) (Umur Inan, iOS memory debugging).

Because jetsam kills leave no crash log, solid logging practices are what let you reconstruct what happened before the app vanished.

A missing deinit log is the tell. If a view controller's deinit never fires after dismissal, something still holds a strong reference, and that object is now a watchdog termination waiting to happen.

When do memory leaks occur under ARC?

A strong reference cycle forms when two objects hold strong references to each other, so ARC's reference count on each never drops to zero and neither ever deallocates. The textbook example is a parent holding a strong reference to a child, and that child holding a strong reference back to its parent.

Switching one side of the pair to a weak or unowned reference type breaks the cycle without changing how the object behaves elsewhere in the application.

In practice, closures cause most leaks we see in production iOS code, not object pairs. A closure's capture list defaults to strong for every variable it touches, including self. Pass a completion handler from a view controller to a network client, and the closure retains self while the client retains the closure, a cycle that survives as long as the server holds the reference.

SwiftUI does not remove this problem. A @StateObject view model that captures self inside a Combine sink or an async Task closure leaks exactly like a UIKit view controller, even though the view struct itself is transient, a gap worth planning for when structuring a SwiftUI codebase around clean architecture from the start.

Healthcare applications serving large customer bases show this at scale. Halodoc, the Indonesian healthcare platform, has documented how it moved from reactively chasing retain cycles to a runtime observability pipeline that catches leaks during real app usage before they ship (Halodoc engineering). Using Instruments to profile these paths, rather than reasoning about them abstractly, is what catches leaks before they reach customers.

According to Apple's Automatic Reference Counting documentation, objects deallocate only when their strong reference count reaches zero, a cycle guarantees it never does.

What is a retain cycle (strong reference cycle)?

A retain cycle, also called a strong reference cycle, happens when two objects each hold a strong reference to the other, so Automatic Reference Counting never drops either retain count to zero. Memory for both objects stays allocated even after every external reference is gone, and neither deinit ever fires.

class Client {
 var server: Server?
}

class Server {
 var client: Client? // strong by default
}

let client = Client()
let server = Server()
client.server = server
server.client = client

Here client and server reference each other with strong properties. Set the local variables to nil and both objects should be released, but ARC sees a non-zero retain count on each and leaves them in memory. According to Apple's ARC documentation, this is the one memory problem ARC cannot resolve on its own, which is why breaking the cycle with a weak or unowned reference on one side is the standard fix, not an optional style choice.

How to check an iOS app for memory leaks with Instruments

Open the Instruments Allocations and Leaks tool from Xcode's profiling menu, attach it to a running build, and watch the generation count column while you repeat the suspect flow. Push a view controller, pop it back, and do this three to five times before drawing any conclusion.

Apple's Instruments User Guide advises repeating the action several times before checking the generation count, since a single pass cannot distinguish a genuine leak from normal caching behavior. Mark a generation after each cycle. If the generation count for your view controller class never returns to zero, ARC is holding a strong reference somewhere in the object graph and deinit is not firing.

A deinit log is the fastest way to confirm a fix. Before applying weak self in a closure capture list, the log stays silent on pop. After:

class ProfileViewController: UIViewController {
 deinit {
 print("ProfileViewController deallocated")
 }
}

Seeing that print statement fire on every pop, with the generation count dropping back to zero in Instruments, is the confirmation a strong reference cycle has been broken.

The Leaks instrument itself only flags cycles it can detect through heap analysis; it misses retained closures that keep growing an object graph without forming a classic cycle. Generation count catches both. When Instruments points to a specific class but not the exact reference, the Memory Graph Debugger is the next tool to reach for.

Detecting retain cycles with the Memory Graph Debugger

The Memory Graph Debugger shows a retain cycle as a literal loop of arrows in Xcode's debug navigator, which beats scanning generation counts when you need to know exactly which object holds which. Click the memory icon while your app is paused on a breakpoint, and Xcode renders every live object with reference arrows between them.

Purple exclamation marks flag objects Xcode suspects are leaked because their reference chain forms a closed loop with no external owner.

Walk the graph from a suspect view controller outward. A strong reference cycle typically shows self pointing to a child object (a delegate, a closure, a child view model) that points straight back with its own strong reference instead of a weak or unowned one.

A common pattern this way of tracing catches fast: a network client holding a completion closure that captures self strongly, while self holds the client as a property, a classic two-node cycle confirmed visually in seconds rather than by repeated Instruments passes.

Fixing it means rewriting the capture list to [weak self] and re-running the graph. A deinit log firing afterward confirms the object is finally released.

Fixing leaks with weak and unowned references

Fixing a strong reference cycle means deciding which side of the relationship should stop counting. Use a weak reference when the referenced object can legitimately become nil during the closure's lifetime; use an unowned reference when you can guarantee the object outlives the closure and never expect nil.

Both go in the closure capture list, right after the opening brace:

final class FeedViewController: UIViewController {
 private var loadTask: Task<Void, Never>?

 func loadFeed() {
 loadTask = Task { [weak self] in
 guard let self else { return }
 let items = await self.networkClient.fetchItems()
 self.apply(items)
 }
 }

 deinit {
 print("FeedViewController deallocated")
 }
}

Before this fix, popping the controller off the navigation stack never printed the deinit log, and the Instruments generation count for FeedViewController stayed pinned at one across repeated pushes. After adding [weak self], the console prints FeedViewController deallocated on every pop, and the generation count returns to zero.

The same pattern applies whenever a network client captures a completion handler while waiting on a server response, or a Timer closure holds self for its full run loop. Reach for unowned instead of weak only in delegate-style or parent-child references where the parent's lifetime strictly bounds the child's, a wrong unowned reference doesn't leak, it crashes on a dangling pointer.

Apple's Automatic Reference Counting guide documents both qualifiers and their tradeoffs against strong references.

Leak sources beyond closures: Delegates, NotificationCenter, timers

Closures are not the only retain cycle source. The delegate pattern still leaks when a protocol property is declared strong instead of weak: a parent view controller holding a strong reference to a child, which holds a strong reference back, blocks both from ever being deallocated.

Apple's own UITableViewDelegate convention exists precisely to head this off. Custom protocols need weak var delegate: explicitly, using a class-bound type so the weak or unowned keyword compiles; for example, protocol SomeDelegate: AnyObject enforces this at the type level.

A NotificationCenter observer registered with a block-based API captures self the same way a closure does. If you never call removeObserver, the object persists as a zombie past its expected lifetime, still responding to app-wide events. Timer.scheduledTimer is worse: the timer itself holds a strong reference to its target, so the object cannot deallocate until the timer is invalidated, even if every other reference has already gone out of scope.

Checking for leaks with NSZombie

NSZombie catches use-after-free crashes, not retain cycle leaks, enable it when a released object still receives a message and the app crashes with a dangling pointer instead of leaking memory quietly.

Turn it on via the Xcode scheme's Diagnostics tab (Enable Zombie Objects). Instead of deallocating an object, the runtime swaps its memory for a zombie proxy that logs -[ZombieClassName respondsToSelector:]: message sent to deallocated instance on the next call. That trace gives you the exact class and selector, faster than reasoning back from a generic EXC_BAD_ACCESS.

It is legacy compared to the Memory Graph Debugger, but for a client build crashing intermittently from a stale delegate reference or an unowned reference firing after dealloc, zombie mode still finds the broken reference faster than stepping through Instruments Allocations manually.

Team checklist for preventing retain cycles at scale

A code review checklist catches strong reference cycle risks before they ship, which is cheaper than chasing them in production with Instruments. Make retain cycle review a standing line item, not an afterthought, on every pull request that touches closures or delegates.

Our review checklist for a growing iOS team:

  • Flag every closure capturing self, require an explicit capture list with weak or unowned, per Apple's ARC guidance.
  • Default delegate and parent-child properties to a weak reference; strong references between parent and child are the most common source of a leak.
  • Audit SwiftUI @StateObject/closures passed into .onReceive or .task the same way, SwiftUI's declarative body doesn't exempt it from strong reference cycle bugs.
  • Run the Memory Graph Debugger on any view controller flagged for reuse or repeated push/pop.
  • Watch for watchdog termination reports in crash logs, a symptom of unbounded memory growth, not just a live leak.

FAQ: iOS memory leaks

Can weak or unowned references leak?

Rarely, but yes. A weak unowned reference avoids retain cycles, yet if you force-unwrap an unowned property after its object is deallocated, the app crashes rather than leaks. Misusing weak in closures without proper capture can still mask cycles elsewhere.

How do I test for leaks in unit tests?

Use XCTestCase's addTeardownBlock with a weak reference to the object under test, then assert it's nil after the test runs. This confirms deallocation without relying on Instruments for every check.

Does SwiftUI prevent leaks?

Not automatically. SwiftUI's structs reduce some retain-cycle risk, but @State, closures, and ObservableObject classes can still leak if you capture self strongly inside a callback or Combine subscription.

What's a real-world example?

Large-scale apps in healthcare and logistics, for example Halodoc in Indonesia, rely on continuous Instruments profiling since a single leaked view controller in a customer-facing application can degrade performance across thousands of daily sessions, whether it's a car-booking flow or a chat feature.

What type of leak is most common?

Retain cycles between closures and view controllers, typically fixed using [weak self] capture lists.

Get help building leak-free iOS apps

Retain cycles are cheap to catch early and expensive to chase once an app is live and users start reporting mystery slowdowns and random closes. Baking Instruments profiling and Memory Graph Debugger checks into code review, not just pre-release QA, is what keeps memory discipline from depending on any one engineer remembering to check.

If your team needs hands-on iOS expertise, from architecture decisions that prevent leaks in the first place to auditing an existing codebase for retain cycles, Netguru's iOS app development team can help. Get an estimate for your project.

We're Netguru

At Netguru we specialize in designing, building, shipping and scaling beautiful, usable products with blazing-fast efficiency.

Let's talk business