Maybe I’m weird, but I kind of love memory leaks. Today, we're going to build a system that detects leaks in your app automatically. With about 30 lines of code.
Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every week.
Full subscribers unlock Quick Hacks, my advanced tips series, and enjoy my long-form articles 3 weeks before anyone else.
Maybe I’m weird, but I kind of love memory leaks.
They’re a fun detective game.
It’s a thrill to spot them in the wild: a weeeeird bug, such as behaviour in the wrong place, or an action mysteriously firing twice. The giddy excitement when you realise the culprit is a memory leak. The godlike feeling of triumph when you find the source and fix it.
Today, we're going to build a system that detects leaks in your app automatically. With about 30 lines of code.
“But Jacob”, you ask, “why would you deprive yourself of the pleasure of finding leaks?”
By the time I’ve had my fun, we’ve left thousands of annoyed DAUs in our wake. And we made our product manager sad.
You can follow along (and copy) my open-source codebase here.
I never learned what a memory leak is
If you’re newish to software engineering, you’ve probably heard of a memory leak—you might also know to avoid it using [weak self]—but you might not have a strong intuition yet.
Let’s cover the basics so we can follow along together when building the detective tool.
Understanding Memory
Memory is temporary storage used by software programs. It lives in your iPhone RAM chips (with a little bit cached close to the CPU, for ultra-fast access).

The operating system creates an abstraction over this hardware memory: the “virtual address space”. This is just a huge red-black tree that maps a virtual memory address to a physical location in RAM. The OS exposes system functions like fork()
which allows processes (i.e. apps) to take up memory in this address space.
The Swift Runtime creates and manages “the heap”, a slice of this virtual memory allocated for your app’s process. Reference types—classes, actors, and closures—are stored on the heap, with a pointer (a.k.a. a reference) to the memory address of this stored data.
I never miss a chance to shill myself, here’s some further reading if you’re interested in learning more about low-level memory internals.
Memory Management
All programming languages manage memory with references to objects stored on the heap. But they diverge drastically in how this memory is cleaned up.
This is critical because, even on modern 8GB iPhones, memory is limited. The system starts to kill apps as if too much is used, so app devs need to be mindful about tidying up memory.
There are several common memory management techniques:
C-style manual memory management with calls to
malloc()
andfree()
to directly create and release memory.Garbage collection, used in JavaScript and JVM languages, where an algorithm periodically traverses the heap and kills orphans.
Rust (and, more recently, Swift) has an ownership-based memory model, a form of manual memory management where objects have one single owner. Memory can be borrowed or consumed to move it around.
The bread-and-butter on iOS is reference counting. Objects in memory track the number of references pointing at them. When their (strong) reference count hits zero, they are deallocated and freed from memory.
Retain Cycles and Memory Leaks
Reference counting is pretty efficient, creating clearly defined lifetimes for objects and quickly cleaning them when no longer used. But it has one very big footgun: retain cycles.
If two objects (strongly) reference each other, they will both have at least one strong reference, so neither will ever be deallocated—they keep each other alive. For example, here’s a view model and service that strongly reference each other.
class ContentViewModel {
let service: Service
func setService() {
self.service = Service(viewModel: self)
}
}
class Service {
let viewModel: ContentViewModel
}
This leak is clearly visible in the memory graph debugger hierarchy.
When these objects are no longer accessible, e.g. if you dismiss the screen containing the view model, the strong references still exist and their memory is never cleaned up. This is a memory leak.
While taking up unnecessary memory is bad enough, the retained objects can lead to cascading disasters. Leaked objects often have their own strong references, keeping more objects alive, and so on, for the entire memory graph underneath the leaked entity. Any timer, listener, or closure might still fire and cause chaos across your app.
The standard way to avoid retain cycles in iOS is by using weak
and unowned
references. These allow child objects to reference their parent without adding to the strong reference count.
class ContentViewModel {
let service: Service
func setService() {
self.service = Service(viewModel: self)
}
}
class Service {
// the weak reference won't keep the view model alive
weak var viewModel: ContentViewModel?
}
Now we’re all experts on memory, let’s apply this theory: building a system to automatically catch leaks.
Implementing The System
Take a look at my codebase on GitHub.
I’ve set up a SwiftUI app with an everyday MVVM approach, using a view model on each screen. To share common code and services between all our screen view models, I’ve used a BaseViewModel
base class.
This shared code allows us to make a single change to the base class and apply the tooling to every single screen for free!
In UIKit-land, we can do the same thing—usually with a BaseViewController
.
I’m still working out the approach to use with MV, but I suspect leaks are less commonplace when your entire navigation structure lives on the stack. An exercise for the reader!
The Detection Protocol
As with many pluggable tools in Swift, we begin with a protocol: LeakDetectable
.
This adds properties and behaviour to the screens we want to track. It should do two things:
Conform to
AnyObject
, so only reference types are detected.Store
maxInstances
—if we detect more than this number, there’s a leak.
Your app may support several of the same screens in memory—e.g. navigating between UserProfileScreens. Some screens therefore need a higher maxInstances. But we can handily default to 1 for the majority of screens.
protocol LeakDetectable: AnyObject {
var maxInstances: Int { get }
}
extension LeakDetectable {
var maxInstances: Int { 1 }
}
Tracking on init
A memory leak is when a class that should be deallocated, isn’t.
The “classical” way to spot leaks is very straightforward - you log when the class deinits—a leaked instance simply won’t print anything.
final class CountingViewModel: BaseViewModel {
deinit {
print("CountingViewModel deinit")
}
}
This is fine when you are testing for the presence of a leak, or verifying a fix, but it’s really easy to miss in your normal workflow. Not good enough.
It’s tough to automatically detect the absence of something. But it’s very simple to check whether there are too many instances.
Therefore, we will design our tool to work on init rather than on deinit.
Checking Our Instances
Now we can apply this protocol to our BaseViewModel
*:
class BaseViewModel: LeakDetectable {
init() {
LeakDetector.shared.check(self)
}
}
On init, we pass a reference to the view model (self) into our leak detector, a singleton service where the magic happens.
*Our BaseViewModel gives protocol conformance for free on all screens, but you can still use the technique if you don’t follow this pattern—just manually conform screens to the protocol.
The Leak Detector
The most important component of our system is the detection mechanism itself.
Since we call this on init (or viewDidLoad), this will immediately flag whether there are leaked instances as you navigate into a LeakDetectable screen.
final class LeakDetector {
static let shared = LeakDetector()
func check(_ instance: LeakDetectable) { }
}
Let’s apply some theory to get this behaving.
Counting Instances
We are checking for leaks on init for each screen in our app. This will count how many instances of our view model (or view controller) are currently alive in memory.
We can achieve this by storing a weak reference to each instance.
When checking for leaks, we count how many of these weak references are still alive, and still point to an object in memory. Weak references allow us to inspect these object lifetimes without affecting the lifetime.
First, let’s make a wrapper class, WeakRef, that creates and stores a weak reference to any object.
private final class WeakRef {
weak var ref: AnyObject?
var isDeallocated: Bool { ref == nil }
init(_ ref: AnyObject?) {
self.ref = ref
}
}
We can then create another class, WeakRefStore, which stores, filters, and counts a list of these weak references.
private final class WeakRefStore {
private var refs = [WeakRef]()
func numberOfLiveInstances(including ref: AnyObject) -> Int {
refs.append(WeakRef(ref))
refs = refs.filter { !$0.isDeallocated }
return refs.count
}
}
All the work is done in numberOfLiveInstances(including:)
.
We pass our class instance to the store, which creates and stores a weak reference.
The store self-cleans, filtering out deallocated weak references
We can then count the living instances in memory and return the number.
We’re nearly there, but there’s one thing missing: How to count instances for different classes?
Counting Each Class
To passively track leaks, we want to count references for each unique class, separately. A key-value store like a dictionary is ideal, keyed by the name of the class.
We can use String(describing:) to create these keys, converting an instance of LeakDetectable into “Leaky.ContentViewModel”.
This dictionary, refStore, can live in the LeakDetector singleton. Now, each class name is the key to a store of all their living instances.
final class LeakDetector {
static let shared = LeakDetector()
private var refStore = [String: WeakRefStore]()]
func check(_ instance: LeakDetectable) {
let className = String(describing: instance)
…
}
}
Putting this all together, we add logic to create a dictionary and WeakRefStore for each class, and check whether the numberOfLiveInstances is within the maxInstances limit.
final class LeakDetector {
static let shared = LeakDetector()
private var refStore = [String: WeakRefStore]()]
func check(_ instance: LeakDetectable) {
let className = String(describing: instance)
if refStore[className] == nil {
refStore[className] = WeakRefStore()
}
let instances = refStore[className]?
.numberOfLiveInstances(including: instance) ?? 0
assert(
instances <= instance.maxInstances,
"Memory leak detected: \(className)"
)
}
}
Let’s see it in action.
Our CountingViewModel in my open-source codebase inherits from BaseViewModel, which applies this leak checking behaviour for free. The view model subclass includes a Timer with a common memory leak, strongly retaining self in the closure.
@Observable
final class CountingViewModel: BaseViewModel {
var count = 0
@ObservationIgnored
private var timer: Timer?
func startCounting() {
timer = Timer.scheduledTimer(
withTimeInterval: 1.0, repeats: true
) { _ in
self.count += 1
}
}
}
We open and dismiss the CountingView, and nothing happens. But when we open the screen a second time, our assertion fires and our app crashes. The leak detector has discovered a leaked CountingViewModel.
Alternative Alerting Systems
I used an assert to immediately crash my code in debug, for maximum visibility.
There are alternative approaches—you could send warnings through OSLog, you can present an alert via NotificationCenter, maybe you might just output a series of very aggressive print statements.
Before I forget, we don’t want to do any of this work in production. Wrap the LeakDetector logic with #if DEBUG.
class BaseViewModel: LeakDetectable {
init() {
#if DEBUG
LeakDetector.shared.check(self)
#endif
}
}
Limitations
This approach is fantastic for passively catching memory leaks, but it isn’t without its flaws—critically, it only checks for leaks within screens.
Without customisation, it won’t do any work with services, models, or navigation logic itself. There are myriad opportunities for leaks to happen between mismanaged concurrency, closures, and strong references throughout your codebase.
Furthermore, restricting to debug mode means the buck stops at the developers to find leaks. At my current company, we can toggle a toggle a floating debug window in ad-hoc builds, allows QA to detect these leaks too. We throw the screen names into user defaults so they can be easily read out.
The Synchronization Framework
Right now, our code should be pretty safe, since all our navigation happens on the main thread.
If you begin to pass service and model instances to LeakDetector, you may run afoul of data race safety with the dictionary. This isn’t thread-safe, and might crash out with EXC_BAD_ACCESS if written to by multiple threads simultaneously.
We can enforce thread-safe access to refStore by converting our dictionary into a shiny new Swift 6 Mutex.
import Synchronization
final class LeakDetector {
static let shared = LeakDetector()
// You can also use OSAllocatedUnfairLock to make the access thread-safe before iOS 18
private let refStore = Mutex([String: WeakRefStore]())
func check(_ instance: LeakDetectable) {
let className = String(describing: instance)
refStore.withLock { store in
if store[className] == nil {
store[className] = WeakRefStore()
}
let instances = store[className]?.numberOfLiveInstances(including: instance) ?? 0
assert(instances <= instance.maxInstances, "Memory leak detected: \(className)")
}
}
}
This works exactly the same as before:
Learn more about Mutex and Atomics in my recent piece:
Conclusion
Memory leaks are a fun detective game. However, the fun is best had in QA.
By understanding the internals of Swift memory management, we can create a system which stores weak references to screens as they are created. This means every time we navigate to a screen, we can check for leaked instances and flag them, before sharing the fun with our production user-base.
`we can toggle a toggle` small typo here. I don’t know how to actually report typos