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.
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.







