| name | memory-debugging |
| description | Use when you see memory warnings, 'retain cycle', app crashes from memory pressure, or when asking 'why is my app using so much memory', 'how do I find memory leaks', 'my deinit is never called', 'Instruments shows memory growth', 'app crashes after 10 minutes' - systematic memory leak detection and fixes for iOS/macOS |
| skill_type | discipline |
| version | 1.0.0 |
| mcp | [object Object] |
Memory Debugging
Overview
Memory issues manifest as crashes after prolonged use. Core principle 90% of memory leaks follow 3 patterns (retain cycles, timer/observer leaks, collection growth). Diagnose systematically with Instruments, never guess.
Example Prompts
These are real questions developers ask that this skill is designed to answer:
1. "My app crashes after 10-15 minutes of use, but there are no error messages. How do I figure out what's leaking?"
→ The skill covers systematic Instruments workflows to identify memory leaks vs normal memory pressure, with real diagnostic patterns
2. "I'm seeing memory jump from 50MB to 200MB+ when I perform a specific action. Is this a leak or normal caching behavior?"
→ The skill distinguishes between progressive leaks (continuous growth) and temporary spikes (caches that stabilize), with diagnostic criteria
3. "View controllers don't seem to be deallocating after I dismiss them. How do I find the retain cycle causing this?"
→ The skill demonstrates Memory Graph Debugger techniques to identify objects holding strong references and common retain cycle patterns
4. "I have timers/observers in my code and I think they're causing memory leaks. How do I verify and fix this?"
→ The skill covers the 5 diagnostic patterns, including specific timer and observer leak signatures with prevention strategies
5. "My app uses 200MB of memory and I don't know if that's normal or if I have multiple leaks. How do I diagnose?"
→ The skill provides the Instruments decision tree to distinguish normal memory use, expected caches, and actual leaks requiring fixes
Red Flags — Memory Leak Likely
If you see ANY of these, suspect memory leak not just heavy memory use:
- Progressive memory growth: 50MB → 100MB → 200MB (not plateauing)
- App crashes after 10-15 minutes with no error in Xcode console
- Memory warnings appear repeatedly in device logs
- Specific screen/operation makes memory jump (10-50MB spike)
- View controllers don't deallocate after dismiss (visible in Memory Graph Debugger)
- Same operation run multiple times causes linear memory growth
Difference from normal memory use
- Normal: App uses 100MB, stays at 100MB (memory pressure handled by iOS)
- Leak: App uses 50MB, becomes 100MB, 150MB, 200MB → CRASH
Mandatory First Steps
ALWAYS run these commands/checks FIRST (before reading code):
# 1. Check device logs for memory warnings
# Connect device, open Xcode Console (Cmd+Shift+2)
# Trigger the crash scenario
# Look for: "Memory pressure critical", "Jetsam killed", "Low Memory"
# 2. Check which objects are leaking
# Use Memory Graph Debugger (below) — shows object count growth
# 3. Check instruments baseline
# Xcode → Product → Profile → Memory
# Run for 1 minute, note baseline
# Perform operation 5 times, note if memory keeps growing
What this tells you
- Memory stays flat → Likely not a leak, check memory pressure handling
- Memory grows linearly → Classic leak (timer, observer, closure capture)
- Sudden spikes then flattens → Probably normal (caches, lazy loading)
- Spikes AND keeps growing → Compound leak (multiple leaks stacking)
Why diagnostics first
- Finding leak with Instruments: 5-15 minutes
- Guessing and testing fixes: 45+ minutes
Quick Decision Tree
Memory growing?
├─ Progressive growth every minute?
│ └─ Likely retain cycle or timer leak
├─ Spike when action performed?
│ └─ Check if operation runs multiple times
├─ Spike then flat for 30 seconds?
│ └─ Probably normal (collections, caches)
├─ Multiple large spikes stacking?
│ └─ Compound leak (multiple sources)
└─ Can't tell from visual inspection?
└─ Use Instruments Memory Graph (see below)
Detecting Leaks — Step by Step
Step 1: Memory Graph Debugger (Fastest Leak Detection)
1. Open your app in Xcode simulator
2. Click: Debug → Memory Graph Debugger (or icon in top toolbar)
3. Wait for graph to generate (5-10 seconds)
4. Look for PURPLE/RED circles with "⚠" badge
5. Click them → Xcode shows retain cycle chain
What you're looking for
✅ Object appears once
❌ Object appears 2+ times (means it's retained multiple times)
Example output (indicates leak)
PlayerViewModel
↑ strongRef from: progressTimer
↑ strongRef from: TimerClosure [weak self] captured self
↑ CYCLE DETECTED: This creates a retain cycle!
Step 2: Instruments (Detailed Memory Analysis)
1. Product → Profile (Cmd+I)
2. Select "Memory" template
3. Run scenario that causes memory growth
4. Perform action 5-10 times
5. Check: Does memory line go UP for each action?
- YES → Leak confirmed
- NO → Probably not a leak
Key instruments to check
- Heap Allocations: Shows object count
- Leaked Objects: Direct leak detection
- VM Tracker: Shows memory by type
- System Memory: Shows OS pressure
How to read the graph
Time ──→
Memory
│ ▗━━━━━━━━━━━━━━━━ ← Memory keeps growing (LEAK)
│ ▄▀
│ ▄▀
│ ▄
└─────────────────────
Action 1 2 3 4 5
vs normal pattern:
Time ──→
Memory
│ ▗━━━━━━━━━━━━━━━━━━ ← Memory plateaus (OK)
│ ▄▀
│▄
└─────────────────────
Action 1 2 3 4 5
Step 3: View Controller Memory Check
For SwiftUI or UIKit view controllers:
// SwiftUI: Check if view disappears cleanly
@main
struct DebugApp: App {
init() {
NotificationCenter.default.addObserver(
forName: NSNotification.Name("UIViewControllerWillDeallocate"),
object: nil,
queue: .main
) { _ in
print("✅ ViewController deallocated")
}
}
var body: some Scene { ... }
}
// UIKit: Add deinit logging
class MyViewController: UIViewController {
deinit {
print("✅ MyViewController deallocated")
}
}
// SwiftUI: Use deinit in view models
@MainActor
class ViewModel: ObservableObject {
deinit {
print("✅ ViewModel deallocated")
}
}
Test procedure
1. Add deinit logging above
2. Launch app in Xcode
3. Navigate to view/create ViewModel
4. Navigate away/dismiss
5. Check Console: Do you see "✅ deallocated"?
- YES → No leak there
- NO → Object is retained somewhere
Common Memory Leak Patterns (With Fixes)
Pattern 1: Timer Leaks (Most Common)
❌ Leak — Timer retains closure, closure retains self
@MainActor
class PlayerViewModel: ObservableObject {
@Published var currentTrack: Track?
private var progressTimer: Timer?
func startPlayback(_ track: Track) {
currentTrack = track
// LEAK: Timer.scheduledTimer captures 'self' in closure
// Even with [weak self], the Timer itself is strong
progressTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.updateProgress()
}
// Timer is never stopped → keeps firing forever
}
// Missing: Timer never invalidated
deinit {
// LEAK: If timer still running, deinit never called
}
}
Leak mechanism
ViewController → strongly retains ViewModel
↓
ViewModel → strongly retains Timer
↓
Timer → strongly retains closure
↓
Closure → captures [weak self] but still holds reference to Timer
Closure captures self weakly BUT
- Timer is still strong reference in ViewModel
- Timer is still running (repeats: true)
- Even with [weak self], timer closure doesn't go away
✅ Fix 1: Invalidate on deinit
@MainActor
class PlayerViewModel: ObservableObject {
@Published var currentTrack: Track?
private var progressTimer: Timer?
func startPlayback(_ track: Track) {
currentTrack = track
progressTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.updateProgress()
}
}
func stopPlayback() {
progressTimer?.invalidate()
progressTimer = nil // Important: nil after invalidate
currentTrack = nil
}
deinit {
progressTimer?.invalidate() // ← CRITICAL FIX
progressTimer = nil
}
}
✅ Fix 2: Use AnyCancellable (Modern approach)
@MainActor
class PlayerViewModel: ObservableObject {
@Published var currentTrack: Track?
private var cancellable: AnyCancellable?
func startPlayback(_ track: Track) {
currentTrack = track
// Timer with Combine - auto-cancels when cancellable is released
cancellable = Timer.publish(
every: 1.0,
tolerance: 0.1,
on: .main,
in: .default
)
.autoconnect()
.sink { [weak self] _ in
self?.updateProgress()
}
}
func stopPlayback() {
cancellable?.cancel() // Auto-cleans up
cancellable = nil
currentTrack = nil
}
// No need for deinit — Combine handles cleanup
}
✅ Fix 3: Weak self + nil check (Emergency fix)
@MainActor
class PlayerViewModel: ObservableObject {
@Published var currentTrack: Track?
private var progressTimer: Timer?
func startPlayback(_ track: Track) {
currentTrack = track
// If progressTimer already exists, stop it first
progressTimer?.invalidate()
progressTimer = nil
progressTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self = self else {
// If self deallocated, timer still fires but does nothing
// Still not ideal - timer keeps consuming CPU
return
}
self.updateProgress()
}
}
func stopPlayback() {
progressTimer?.invalidate()
progressTimer = nil
}
deinit {
progressTimer?.invalidate()
progressTimer = nil
}
}
Why the fixes work
invalidate(): Stops timer immediately, breaks retain cyclecancellable: Automatically invalidates when released[weak self]: If ViewModel released before timer, timer becomes no-opdeinit cleanup: Ensures timer always cleaned up
Test the fix
func testPlayerViewModelNotLeaked() {
var viewModel: PlayerViewModel? = PlayerViewModel()
let track = Track(id: "1", title: "Song")
viewModel?.startPlayback(track)
// Verify timer running
XCTAssertNotNil(viewModel?.progressTimer)
// Stop and deallocate
viewModel?.stopPlayback()
viewModel = nil
// ✅ Should deallocate without leak warning
}
Pattern 2: Observer/Notification Leaks
❌ Leak — Observer holds strong reference to self
@MainActor
class PlayerViewModel: ObservableObject {
init() {
// LEAK: addObserver keeps strong reference to self
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAudioSessionChange),
name: AVAudioSession.routeChangeNotification,
object: nil
)
// No matching removeObserver → accumulates listeners
}
@objc private func handleAudioSessionChange() { }
deinit {
// Missing: Never unregistered
}
}
✅ Fix 1: Manual cleanup in deinit
@MainActor
class PlayerViewModel: ObservableObject {
init() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAudioSessionChange),
name: AVAudioSession.routeChangeNotification,
object: nil
)
}
@objc private func handleAudioSessionChange() { }
deinit {
NotificationCenter.default.removeObserver(self) // ← FIX
}
}
✅ Fix 2: Use modern Combine approach (Best practice)
@MainActor
class PlayerViewModel: ObservableObject {
private var cancellables = Set<AnyCancellable>()
init() {
NotificationCenter.default.publisher(
for: AVAudioSession.routeChangeNotification
)
.sink { [weak self] _ in
self?.handleAudioSessionChange()
}
.store(in: &cancellables) // Auto-cleanup with viewModel
}
private func handleAudioSessionChange() { }
// No deinit needed - cancellables auto-cleanup
}
✅ Fix 3: Use @Published with map (Reactive)
@MainActor
class PlayerViewModel: ObservableObject {
@Published var currentRoute: AVAudioSession.AudioSessionRouteDescription?
private var cancellables = Set<AnyCancellable>()
init() {
NotificationCenter.default.publisher(
for: AVAudioSession.routeChangeNotification
)
.map { _ in AVAudioSession.sharedInstance().currentRoute }
.assign(to: &$currentRoute) // Auto-cleanup with publisher chain
}
}
Pattern 3: Closure Capture Leaks (Collection/Array)
❌ Leak — Closure captured in array, captures self
@MainActor
class PlaylistViewController: UIViewController {
private var tracks: [Track] = []
private var updateCallbacks: [(Track) -> Void] = [] // LEAK SOURCE
func addUpdateCallback() {
// LEAK: Closure captures 'self'
updateCallbacks.append { [self] track in
self.refreshUI(with: track) // Strong capture of self
}
// updateCallbacks grows and never cleared
}
// No mechanism to clear callbacks
deinit {
// updateCallbacks still references self
}
}
Leak mechanism
ViewController
↓ strongly owns
updateCallbacks array
↓ contains
Closure captures self
↓ CYCLE
Back to ViewController (can't deallocate)
✅ Fix 1: Use weak self in closure
@MainActor
class PlaylistViewController: UIViewController {
private var tracks: [Track] = []
private var updateCallbacks: [(Track) -> Void] = []
func addUpdateCallback() {
updateCallbacks.append { [weak self] track in
self?.refreshUI(with: track) // Weak capture
}
}
deinit {
updateCallbacks.removeAll() // Clean up array
}
}
✅ Fix 2: Use unowned (when you're certain self lives longer)
@MainActor
class PlaylistViewController: UIViewController {
private var updateCallbacks: [(Track) -> Void] = []
func addUpdateCallback() {
updateCallbacks.append { [unowned self] track in
self.refreshUI(with: track) // Unowned is faster
}
// Use unowned ONLY if callback always destroyed before ViewController
}
deinit {
updateCallbacks.removeAll()
}
}
✅ Fix 3: Cancel callbacks when done (Reactive)
@MainActor
class PlaylistViewController: UIViewController {
private var cancellables = Set<AnyCancellable>()
func addUpdateCallback(_ handler: @escaping (Track) -> Void) {
// Use PassthroughSubject instead of array
Just(())
.sink { [weak self] in
handler(/* track */)
}
.store(in: &cancellables)
}
// When done:
func clearCallbacks() {
cancellables.removeAll() // Cancels all subscriptions
}
}
Test the fix
func testCallbacksNotLeak() {
var viewController: PlaylistViewController? = PlaylistViewController()
viewController?.addUpdateCallback { _ in }
// Verify callback registered
XCTAssert(viewController?.updateCallbacks.count ?? 0 > 0)
// Clear and deallocate
viewController?.updateCallbacks.removeAll()
viewController = nil
// ✅ Should deallocate
}
Pattern 4: Strong Reference Cycles (Closures + Properties)
❌ Leak — Two objects strongly reference each other
@MainActor
class Player: NSObject {
var delegate: PlayerDelegate? // Strong reference
var onPlaybackEnd: (() -> Void)? // ← Closure captures self
init(delegate: PlayerDelegate) {
self.delegate = delegate
// LEAK CYCLE:
// Player → (owns) → delegate
// delegate → (through closure) → owns → Player
}
}
class PlaylistController: PlayerDelegate {
var player: Player?
override init() {
super.init()
self.player = Player(delegate: self) // Self-reference cycle
player?.onPlaybackEnd = { [self] in
// LEAK: Closure captures self
// self owns player
// player owns delegate (self)
// Cycle!
self.playNextTrack()
}
}
}
✅ Fix: Break cycle with weak self
@MainActor
class PlaylistController: PlayerDelegate {
var player: Player?
override init() {
super.init()
self.player = Player(delegate: self)
player?.onPlaybackEnd = { [weak self] in
// Weak self breaks the cycle
self?.playNextTrack()
}
}
deinit {
player?.onPlaybackEnd = nil // Optional cleanup
player = nil
}
}
Pattern 5: View/Layout Callback Leaks
❌ Leak — View layout callback retains view controller
@MainActor
class DetailViewController: UIViewController {
let customView = UIView()
override func viewDidLoad() {
super.viewDidLoad()
// LEAK: layoutIfNeeded closure captures self
customView.layoutIfNeeded = { [self] in
// Every layout triggers this, keeping self alive
self.updateLayout()
}
}
}
✅ Fix: Use @IBAction or proper delegation pattern
@MainActor
class DetailViewController: UIViewController {
@IBOutlet weak var customView: CustomView!
override func viewDidLoad() {
super.viewDidLoad()
customView.delegate = self // Weak reference through protocol
}
deinit {
customView?.delegate = nil // Clean up
}
}
protocol CustomViewDelegate: AnyObject { // AnyObject = weak by default
func customViewDidLayout(_ view: CustomView)
}
Pattern 6: PhotoKit Image Request Leaks
❌ Leak — PHImageManager requests accumulate without cancellation
This pattern is specific to photo/media apps using PhotoKit or similar async image loading APIs.
// LEAK: Image requests not cancelled when cells scroll away
class PhotoViewController: UIViewController {
let imageManager = PHImageManager.default()
func collectionView(_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
let asset = photos[indexPath.item]
// LEAK: Requests accumulate - never cancelled
imageManager.requestImage(
for: asset,
targetSize: thumbnailSize,
contentMode: .aspectFill,
options: nil
) { [weak self] image, _ in
cell.imageView.image = image // Still called even if cell scrolled away
}
return cell
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// Each scroll triggers 50+ new image requests
// Previous requests still pending, accumulating in queue
}
}
Symptoms
- Memory jumps 50MB+ when scrolling long photo lists
- Crashes happen after scrolling through 100+ photos
- Specific operation causes leak (photo scrolling, not other screens)
- Works fine locally with 10 photos, crashes on user devices with 1000+ photos
Root cause PHImageManager.requestImage() returns a PHImageRequestID that must be explicitly cancelled. Without cancellation, pending requests queue up and hold memory.
✅ Fix: Store request ID and cancel in prepareForReuse()
class PhotoCell: UICollectionViewCell {
@IBOutlet weak var imageView: UIImageView!
private var imageRequestID: PHImageRequestID = PHInvalidImageRequestID
func configure(with asset: PHAsset, imageManager: PHImageManager) {
// Cancel previous request before starting new one
if imageRequestID != PHInvalidImageRequestID {
imageManager.cancelImageRequest(imageRequestID)
}
imageRequestID = imageManager.requestImage(
for: asset,
targetSize: PHImageManagerMaximumSize,
contentMode: .aspectFill,
options: nil
) { [weak self] image, _ in
self?.imageView.image = image
}
}
override func prepareForReuse() {
super.prepareForReuse()
// CRITICAL: Cancel pending request when cell is reused
if imageRequestID != PHInvalidImageRequestID {
PHImageManager.default().cancelImageRequest(imageRequestID)
imageRequestID = PHInvalidImageRequestID
}
imageView.image = nil // Clear stale image
}
deinit {
// Safety check - shouldn't be needed if prepareForReuse called
if imageRequestID != PHInvalidImageRequestID {
PHImageManager.default().cancelImageRequest(imageRequestID)
}
}
}
// Controller
class PhotoViewController: UIViewController, UICollectionViewDataSource {
let imageManager = PHImageManager.default()
func collectionView(_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "PhotoCell",
for: indexPath) as! PhotoCell
let asset = photos[indexPath.item]
cell.configure(with: asset, imageManager: imageManager)
return cell
}
}
Key points
- Store
PHImageRequestIDin cell (not in view controller) - Cancel BEFORE starting new request (prevents request storms)
- Cancel in
prepareForReuse()(critical for collection views) - Check
imageRequestID != PHInvalidImageRequestIDbefore cancelling
Other async APIs with similar patterns
AVAssetImageGenerator.generateCGImagesAsynchronously()→ callcancelAllCGImageGeneration()URLSession.dataTask()→ callcancel()on task- Custom image caches → implement
invalidate()orcancel()method
Debugging Non-Reproducible Memory Issues
Challenge Memory leak only happens with specific user data (large photo collections, complex data models) that you can't reproduce locally.
Step 1: Enable Remote Memory Diagnostics
Add MetricKit diagnostics to your app:
import MetricKit
class MemoryDiagnosticsManager {
static let shared = MemoryDiagnosticsManager()
private let metricManager = MXMetricManager.shared
func startMonitoring() {
metricManager.add(self)
}
}
extension MemoryDiagnosticsManager: MXMetricManagerSubscriber {
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
if let memoryMetrics = payload.memoryMetrics {
let peakMemory = memoryMetrics.peakMemoryUsage
// Log if exceeding threshold
if peakMemory > 400_000_000 { // 400MB
print("⚠️ High memory: \(peakMemory / 1_000_000)MB")
// Send to analytics
}
}
}
}
}
Step 2: Ask Users for Device Logs
When user reports crash:
- iPhone → Settings → Privacy & Security → Analytics → Analytics Data
- Look for latest crash log (named like
YourApp_2024-01-15-12-45-23) - Email or upload to your support system
- Xcode → Window → Devices & Simulators → select device → View Device Logs
- Search for "Memory" or "Jetsam" in logs
Step 3: TestFlight Beta Testing
Before App Store release:
#if DEBUG
// Add to AppDelegate
import os.log
let logger = os.log(subsystem: "com.yourapp.memory", category: "lifecycle")
// Log memory milestones
func logMemory(_ event: String) {
let memoryUsage = ProcessInfo.processInfo.physicalMemory / 1_000_000
os.log("🔍 [%s] Memory: %dMB", log: logger, type: .info, event, memoryUsage)
}
#endif
Send TestFlight build to affected users:
- Build → Archive → Distribute App
- Select TestFlight
- Add affected user email
- In TestFlight, ask user to:
- Reproduce the crash scenario
- Check if memory stabilizes (logs to system.log)
- Report if crash still happens
Step 4: Verify Fix Production Deployment
After deploying fix:
- Monitor MetricKit metrics for 24-48 hours
- Check crash rate drop in App Analytics
- If still seeing high memory users:
- Add more diagnostic logging for next version
- Consider lower memory device testing (iPad with constrained memory)
Systematic Debugging Workflow
Phase 1: Confirm Leak (5 minutes)
1. Open app in simulator
2. Xcode → Product → Profile → Memory
3. Record baseline memory
4. Repeat action 10 times
5. Check memory graph:
- Flat line = NOT a leak (stop here)
- Steady climb = LEAK (go to Phase 2)
Phase 2: Locate Leak (10-15 minutes)
1. Close Instruments
2. Xcode → Debug → Memory Graph Debugger
3. Wait for graph (5-10 sec)
4. Look for purple/red circles with ⚠
5. Click on leaked object
6. Read the retain cycle chain:
PlayerViewModel (leak)
↑ retained by progressTimer
↑ retained by TimerClosure
↑ retained by [self] capture
Common leak locations (in order of likelihood)
- Timers (50% of leaks)
- Notifications/KVO (25%)
- Closures in arrays/collections (15%)
- Delegate cycles (10%)
Phase 3: Test Hypothesis (5 minutes)
Apply fix from "Common Patterns" section above, then:
// Add deinit logging
class PlayerViewModel: ObservableObject {
deinit {
print("✅ PlayerViewModel deallocated - leak fixed!")
}
}
Run in Xcode, perform operation, check console for dealloc message.
Phase 4: Verify Fix with Instruments (5 minutes)
1. Product → Profile → Memory
2. Repeat action 10 times
3. Confirm: Memory stays flat (not climbing)
4. If climbing continues, go back to Phase 2 (second leak)
Compound Leaks (Multiple Sources)
Real apps often have 2-3 leaks stacking:
Leak 1: Timer in PlayerViewModel (+10MB/minute)
Leak 2: Observer in delegate (+5MB/minute)
Result: +15MB/minute → Crashes in 13 minutes
How to find compound leaks
1. Fix obvious leak (Timer)
2. Run Instruments again
3. If memory STILL growing, there's a second leak
4. Repeat Phase 1-3 for each leak
5. Test each fix in isolation (revert one, test another)
Memory Leak Detection — Testing Checklist
// Pattern 1: Verify object deallocates
@Test func viewModelDeallocates() {
var vm: PlayerViewModel? = PlayerViewModel()
vm?.startPlayback(Track(id: "1", title: "Test"))
// Cleanup
vm?.stopPlayback()
vm = nil
// If no crash, object deallocated
}
// Pattern 2: Verify timer stops
@Test func timerStopsOnDeinit() {
var vm: PlayerViewModel? = PlayerViewModel()
let startCount = Timer.activeCount()
vm?.startPlayback(Track(id: "1", title: "Test"))
XCTAssertGreater(Timer.activeCount(), startCount)
vm?.stopPlayback()
vm = nil
XCTAssertEqual(Timer.activeCount(), startCount)
}
// Pattern 3: Verify observer unregistered
@Test func observerRemovedOnDeinit() {
var vc: DetailViewController? = DetailViewController()
let startCount = NotificationCenter.default.observers().count
// Perform action that adds observer
_ = vc
vc = nil
XCTAssertEqual(NotificationCenter.default.observers().count, startCount)
}
// Pattern 4: Memory stability over time
@Test func memoryStableAfterRepeatedActions() {
let vm = PlayerViewModel()
var measurements: [UInt] = []
for _ in 0..<10 {
vm.startPlayback(Track(id: "1", title: "Test"))
vm.stopPlayback()
let memory = ProcessInfo.processInfo.physicalMemory
measurements.append(memory)
}
// Check last 5 measurements are within 10% of each other
let last5 = Array(measurements.dropFirst(5))
let average = last5.reduce(0, +) / UInt(last5.count)
for measurement in last5 {
XCTAssertLessThan(
abs(Int(measurement) — Int(average)),
Int(average / 10) // 10% tolerance
)
}
}
Command Line Tools for Memory Debugging
# Monitor memory in real-time
# Connect device, then
xcrun xctrace record --template "Memory" --output memory.trace
# Analyze with command line
xcrun xctrace dump memory.trace
# Check for leaked objects
instruments -t "Leaks" -a YourApp -p 1234
# Memory pressure simulator
xcrun simctl spawn booted launchctl list | grep memory
# Check malloc statistics
leaks -atExit -excludeNoise YourApp
Common Mistakes
❌ Using [weak self] but never calling invalidate()
- Weak self prevents immediate crash but doesn't stop timer
- Timer keeps running and consuming CPU/battery
- ALWAYS call
invalidate()orcancel()on timers/subscribers
❌ Invalidating timer but keeping strong reference
// ❌ Wrong
timer?.invalidate() // Stops firing but timer still referenced
// ❌ Should be:
timer?.invalidate()
timer = nil // Release the reference
❌ Assuming AnyCancellable auto-cleanup is automatic
// ❌ Wrong - if cancellable goes out of scope, subscription ends immediately
func setupListener() {
let cancellable = NotificationCenter.default
.publisher(for: .myNotification)
.sink { _ in }
// cancellable is local, goes out of scope immediately
// Subscription dies before any notifications arrive
}
// ✅ Right - store in property
@MainActor
class MyClass: ObservableObject {
private var cancellables = Set<AnyCancellable>()
func setupListener() {
NotificationCenter.default
.publisher(for: .myNotification)
.sink { _ in }
.store(in: &cancellables) // Stored as property
}
}
❌ Not testing the fix
- Apply fix → Assume it's correct → Deploy
- ALWAYS run Instruments after fix to confirm memory flat
❌ Fixing the wrong leak first
- Multiple leaks = fix largest first (biggest memory impact)
- Use Memory Graph to identify what's actually leaking
❌ Adding deinit with only logging, no cleanup
// ❌ Wrong - just logs, doesn't clean up
deinit {
print("ViewModel deallocating") // Doesn't stop timer!
}
// ✅ Right - actually stops the leak
deinit {
timer?.invalidate()
timer = nil
NotificationCenter.default.removeObserver(self)
}
❌ Using Instruments Memory template instead of Leaks
- Memory template: Shows memory usage (not leaks)
- Leaks template: Detects actual leaks
- Use both: Memory for trend, Leaks for detection
Instruments Quick Reference
| Scenario | Tool | What to Look For |
|---|---|---|
| Progressive memory growth | Memory | Line steadily climbing = leak |
| Specific object leaking | Memory Graph | Purple/red circles = leak objects |
| Direct leak detection | Leaks | Red "! Leak" badge = confirmed leak |
| Memory by type | VM Tracker | Find objects consuming most memory |
| Cache behavior | Allocations | Find objects allocated but not freed |
Real-World Impact
Before 50+ PlayerViewModel instances created/destroyed
- Each uncleared timer fires every second
- Memory: 50MB → 100MB (1min) → 200MB (2min) → Crash (13min)
- Developer spends 2+ hours debugging
After Timer properly invalidated in all view models
- One instance created/destroyed = memory flat
- No timer accumulation
- Memory: 50MB → 50MB → 50MB (stable for hours)
Key insight 90% of leaks come from forgetting to stop timers, observers, or subscriptions. Always clean up in deinit or use reactive patterns that auto-cleanup.
Simulator Verification
After fixing memory leaks, verify your app's UI still renders correctly and doesn't introduce visual regressions.
Why Verify After Memory Fixes
Memory fixes can sometimes break functionality:
- Premature cleanup — Object deallocated while still needed
- Broken bindings — Weak references become nil unexpectedly
- State loss — Data cleared too early in lifecycle
Always verify:
- UI still renders correctly
- No blank screens or missing content
- Animations still work
- App doesn't crash on navigation
Quick Visual Verification
# 1. Build with memory fix
xcodebuild build -scheme YourScheme
# 2. Launch in simulator
xcrun simctl launch booted com.your.bundleid
# 3. Navigate to affected screen
xcrun simctl openurl booted "debug://problem-screen"
sleep 1
# 4. Capture screenshot
/axiom:screenshot
# 5. Verify UI looks correct (no blank views, missing images, etc.)
Stress Testing with Screenshots
Test the screen that was leaking, repeatedly:
# Navigate to screen multiple times, capture at each iteration
for i in {1..10}; do
xcrun simctl openurl booted "debug://player-screen?id=$i"
sleep 2
xcrun simctl io booted screenshot /tmp/stress-test-$i.png
done
# All screenshots should look correct (not degraded)
Full Verification Workflow
/axiom:test-simulator
Then describe:
- "Navigate to PlayerView 10 times and verify UI doesn't degrade"
- "Open and close SettingsView repeatedly, screenshot each time"
- "Check console logs for deallocation messages"
Before/After Example
Before fix (timer leak):
# After navigating to PlayerView 20 times:
# - Memory at 200MB
# - UI sluggish
# - Screenshot shows normal UI (but app will crash soon)
After fix (timer cleanup added):
# After navigating to PlayerView 20 times:
# - Memory stable at 50MB
# - UI responsive
# - Screenshot shows normal UI
# - Console logs show: "PlayerViewModel deinitialized" after each navigation
Key verification: Screenshots AND memory both stable = fix is correct
Last Updated: 2025-11-28 Frameworks: UIKit, SwiftUI, Combine, Foundation Status: Production-ready patterns for leak detection and prevention