Why Your Swift App is Secretly Eating Memory (And How ARC is Both Saving and Destroying You)
Master Swift’s memory management before it masters your app’s performance

Your iOS app is consuming memory right now. More than it should be. And chances are, you have no idea it’s happening.
I’m looking at a production app that crashed on 15% of devices last month. Memory warnings. Random freezes. Users complaining about battery drain. The culprit? A single line of innocent-looking Swift code that was creating hundreds of objects that never got cleaned up.
imageDownloader.fetchImage(url: imageURL) { [self] image in
self.imageView.image = image
}Looks harmless, right? This one closure was keeping entire view controller hierarchies alive indefinitely. Classic retain cycle, but the kind that slips through code review because it looks correct.
Here’s what nobody tells you about ARC: it’s simultaneously the best and worst thing about Swift memory management. It handles 95% of memory cleanup automatically, which is brilliant. But that remaining 5%? Those edge cases will silently destroy your app’s performance in ways that are incredibly hard to track down.
After debugging memory issues in dozens of production iOS apps, I’ve learned that ARC isn’t magic — it’s a very specific set of rules. And when those rules don’t match your expectations, your app starts secretly consuming memory until it crashes.
📺 Want to see these memory debugging techniques in action? Check out the complete video tutorial coming soon to Swift Pal: _https://youtube.com/@swift-pal_
**Swift Pal** _👨💻 Welcome to Swift Pal - Your iOS Dev Companion! Whether you're just starting your journey with Swift and SwiftUI…_youtube.comhttps://youtube.com/@swift-pal
🖼️ The Image Cache That Ate Manhattan
Let’s start with the memory killer that’s probably running in your app right now. You know that innocent image caching you added to improve performance? It’s consuming gigabytes of memory.
class ImageCache {
static let shared = ImageCache()
private var cache: [String: UIImage] = [:]
func image(for url: String) -> UIImage? {
return cache[url]
}
func setImage(_ image: UIImage, for url: String) {
cache[url] = image // This line is a memory bomb
}
}Looks reasonable, right? Just a simple dictionary cache. But here’s what’s happening in production:
- User scrolls through a feed with 500 images
- Each 4K image from modern cameras = ~50MB uncompressed in memory
- Your cache now holds 25GB of images
- iOS kills your app
The “secret” here isn’t that caching is bad — it’s that UIImage objects are massive in memory compared to their file size. A 2MB JPEG becomes a 50MB UIImage when decompressed.
Here’s the fix most developers don’t know about:
class SmartImageCache {
static let shared = SmartImageCache()
private var cache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 50 // Maximum number of images
cache.totalCostLimit = 100 * 1024 * 1024 // 100MB limit
return cache
}()
func image(for url: String) -> UIImage? {
return cache.object(forKey: url as NSString)
}
func setImage(_ image: UIImage, for url: String) {
// Calculate memory cost: width * height * 4 bytes per pixel
let cost = Int(image.size.width * image.size.height * 4)
cache.setObject(image, forKey: url as NSString, cost: cost)
}
}NSCache automatically evicts objects under memory pressure. But even this isn't enough for production apps with heavy image usage.
🔄 Where Things Start Going Wrong
Now here’s where it gets interesting (and frustrating). ARC works great until you create what’s called a retain cycle. This happens when two objects hold strong references to each other, creating a loop that never gets broken.
Check out this disaster waiting to happen:
class ViewController: UIViewController {
var dataManager: DataManager?
override func viewDidLoad() {
super.viewDidLoad()
dataManager = DataManager()
dataManager?.delegate = self
}
}
class DataManager {
var delegate: ViewController?
func fetchData() {
// Do some work
delegate?.dataDidLoad()
}
}
extension ViewController: DataManagerDelegate {
func dataDidLoad() {
// Handle the data
}
}
protocol DataManagerDelegate {
func dataDidLoad()
}See the problem? ViewController holds a strong reference to DataManager, and DataManager holds a strong reference back to ViewController. Neither can ever be deallocated because they're keeping each other alive.
This is the classic delegate retain cycle, and I’ve seen it crash production apps when users navigate around quickly and these view controllers just… pile up in memory.
💡 The Weak and Unowned Solution
Fortunately, Swift gives us weak and unowned references to break these cycles. Here's the fixed version:
class DataManager {
weak var delegate: ViewController?
func fetchData() {
// Do some work
delegate?.dataDidLoad()
}
}By making the delegate weak, we're telling Swift: "Don't count this reference when deciding whether to deallocate the view controller." Now when the view controller goes away, it can actually be deallocated, even though the data manager might still be around.
But here’s where developers get confused — when do you use weak vs unowned?
Use **weak** when:
- The referenced object might become
nilduring the lifetime of your object - Delegates, callbacks, parent-child relationships where the parent might disappear first
Use **unowned** when:
- You’re absolutely sure the referenced object will outlive your object
- The relationship is like “this object can’t exist without that object”
Here’s a practical example:
class BlogPost {
let title: String
let author: Author
init(title: String, author: Author) {
self.title = title
self.author = author
}
}
class Author {
let name: String
private var posts: [BlogPost] = []
init(name: String) {
self.name = name
}
func addPost(title: String) {
let post = BlogPost(title: title, author: self)
posts.append(post)
}
}This creates a retain cycle too! Each BlogPost holds a strong reference to its Author, and the Author holds strong references to all its posts.
The fix depends on your domain logic:
class BlogPost {
let title: String
unowned let author: Author // A post can't exist without an author
init(title: String, author: Author) {
self.title = title
self.author = author
}
}I used unowned here because a blog post conceptually can't exist without an author. If the author gets deallocated, something's gone very wrong in my app logic.
🕸️ The Closure Trap
But honestly? The most common memory leaks I see aren’t from delegates. They’re from closures. Closures capture everything they reference, and if you’re not careful, they’ll capture self and create a retain cycle.
Here’s a scenario:
class ImageDownloader {
private let session = URLSession.shared
private var completionHandler: ((UIImage?) -> Void)?
func downloadImage(from url: URL, completion: @escaping (UIImage?) -> Void) {
self.completionHandler = completion
session.dataTask(with: url) { [self] data, response, error in
guard let data = data, let image = UIImage(data: data) else {
self.completionHandler?(nil)
return
}
self.completionHandler?(image)
}.resume()
}
}
class ProfileViewController: UIViewController {
private let imageDownloader = ImageDownloader()
@IBOutlet weak var profileImageView: UIImageView!
override func viewDidLoad() {
super.viewDidLoad()
let url = URL(string: "https://example.com/profile.jpg")!
imageDownloader.downloadImage(from: url) { [self] image in
DispatchQueue.main.async {
self.profileImageView.image = image
}
}
}
}This looks innocent, but it’s a memory leak waiting to happen. The closure captures self (the view controller), and the ImageDownloader holds onto the completion handler. If the user navigates away before the download completes, the view controller stays in memory indefinitely.
The fix is using weak self:
imageDownloader.downloadImage(from: url) { [weak self] image in
DispatchQueue.main.async {
self?.profileImageView.image = image
}
}Actually, let me show you the complete, working version that handles all the edge cases:
class ImageDownloader {
private let session = URLSession.shared
func downloadImage(from url: URL, completion: @escaping (Result<UIImage, Error>) -> Void) {
session.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data, let image = UIImage(data: data) else {
completion(.failure(ImageDownloadError.invalidData))
return
}
completion(.success(image))
}.resume()
}
}
enum ImageDownloadError: Error {
case invalidData
}
class ProfileViewController: UIViewController {
private let imageDownloader = ImageDownloader()
@IBOutlet weak var profileImageView: UIImageView!
override func viewDidLoad() {
super.viewDidLoad()
guard let url = URL(string: "https://example.com/profile.jpg") else { return }
imageDownloader.downloadImage(from: url) { [weak self] result in
DispatchQueue.main.async {
switch result {
case .success(let image):
self?.profileImageView.image = image
case .failure(let error):
print("Failed to download image: \(error)")
// Handle error appropriately
}
}
}
}
}🔍 Debugging Memory Issues Like a Pro
When things go wrong (and they will), you need to know how to debug memory issues. Xcode’s Memory Graph Debugger is your best friend here.
Here’s my debugging workflow:
- Run your app in the simulator
- Navigate to the problematic screen and back several times
- In Xcode, click the Debug Memory Graph button
- Look for objects that should have been deallocated but weren’t

You’ll see a graph showing all objects in memory and their relationships. Retain cycles show up as obvious loops in this graph.
But sometimes you need more detail. That’s when I fire up Instruments.
🚨 Common Patterns That Always Cause Trouble
After years of debugging memory issues, I’ve noticed certain patterns that almost always cause problems:
1\. Notification observers that never get removed:
// BAD
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(
self,
selector: #selector(handleNotification),
name: .userDidLogin,
object: nil
)
}
// GOOD
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(
self,
selector: #selector(handleNotification),
name: .userDidLogin,
object: nil
)
}
deinit {
NotificationCenter.default.removeObserver(self)
}2\. Combine subscriptions that don’t get cancelled:
class DataViewModel: ObservableObject {
@Published var data: [String] = []
private var cancellables = Set<AnyCancellable>()
init() {
// This will automatically cancel when cancellables is deallocated
dataPublisher
.sink { [weak self] newData in
self?.data = newData
}
.store(in: &cancellables)
}
private var dataPublisher: AnyPublisher<[String], Never> {
// Your data source here
Just(["Sample", "Data"])
.eraseToAnyPublisher()
}
}3\. Delegates that should be weak but aren’t:
protocol DataSourceDelegate: AnyObject { // Note: AnyObject protocol
func didReceiveData(_ data: String)
}
class DataSource {
weak var delegate: DataSourceDelegate? // Always weak for delegates
}🎯 Building Memory-Safe Habits
Here’s what I’ve learned after fixing way too many memory leaks: it’s easier to write memory-safe code from the start than to debug it later.
My checklist for every class I write:
- Delegates are weak ✓
- Closure captures use \[weak self\] when they might outlive the object ✓
- Timers and observers get cleaned up in deinit ✓
- Parent-child relationships are carefully designed ✓
And my debugging routine:
- Add deinit messages to every class during development
- Run the Memory Graph Debugger regularly
- Test navigation patterns (push/pop/present/dismiss) repeatedly
🚀 The Bottom Line
ARC is amazing, but it’s not telepathic. It can’t read your mind about ownership relationships. When you understand how reference counting works and where it breaks down, you can write apps that are both fast and memory-efficient.
The key is thinking about object lifetimes when you write code, not just when you’re debugging crashes at 2 AM.
Start small — add those deinit messages, use weak self in closures, and get comfortable with the Memory Graph Debugger. Your future self (and your users’ battery life) will thank you.
🎉 Enjoyed this article? Your support means the world to me!
💬 Drop a comment below! I love hearing about your experiences and answering questions
🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal
💼 Let’s connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal
Happy coding! 🚀
New articles, straight to your inbox.
No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.
Keep reading
Why I'm Rebuilding My Blog From Scratch (and Leaving Medium)
After years of publishing on someone else's platform, I'm moving my writing to a home I actually own. Here's the reasoning, and what I'm building instead.
ReadData Is the Model: The Most Ignored Part of AI
A beginner-friendly guide to why data quality beats model hype.
Read