| name | axiom-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 | {"category":"debugging","tags":["memory","leaks","instruments","retain-cycles","performance","allocations"],"related":["performance-profiling","axiom-objc-block-retain-cycles"]} |
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):
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:
@main
struct DebugApp: App {
init() {
NotificationCenter.default.addObserver(
forName: NSNotification.Name("UIViewControllerWillDeallocate"),
object: nil,
queue: .main
) { _ in
print("✅ ViewController deallocated")
}
}
var body: some Scene { ... }
}
class MyViewController: UIViewController {
deinit {
print("✅ MyViewController deallocated")
}
}
@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
progressTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.updateProgress()
}
}
deinit {
}
}
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
currentTrack = nil
}
deinit {
progressTimer?.invalidate()
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
cancellable = Timer.publish(
every: 1.0,
tolerance: 0.1,
on: .main,
in: .default
)
.autoconnect()
.sink { [weak self] _ in
self?.updateProgress()
}
}
func stopPlayback() {
cancellable?.cancel()
cancellable = nil
currentTrack = nil
}
}
✅ 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
progressTimer?.invalidate()
progressTimer = nil
progressTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self = self else {
return
}
self.updateProgress()
}
}
func stopPlayback() {
progressTimer?.invalidate()
progressTimer = nil
}
deinit {
progressTimer?.invalidate()
progressTimer = nil
}
}
Why the fixes work
invalidate(): Stops timer immediately, breaks retain cycle
cancellable: Automatically invalidates when released
[weak self]: If ViewModel released before timer, timer becomes no-op
deinit 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)
XCTAssertNotNil(viewModel?.progressTimer)
viewModel?.stopPlayback()
viewModel = nil
}
Pattern 2: Observer/Notification Leaks
❌ Leak — Observer holds strong reference to self
@MainActor
class PlayerViewModel: ObservableObject {
init() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAudioSessionChange),
name: AVAudioSession.routeChangeNotification,
object: nil
)
}
@objc private func handleAudioSessionChange() { }
deinit {
}
}
✅ 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 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)
}
private func handleAudioSessionChange() { }
}
✅ 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)
}
}
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] = []
func addUpdateCallback() {
updateCallbacks.append { [self] track in
self.refreshUI(with: track)
}
}
deinit {
}
}
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)
}
}
deinit {
updateCallbacks.removeAll()
}
}
✅ 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)
}
}
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) {
Just(())
.sink { [weak self] in
handler()
}
.store(in: &cancellables)
}
func clearCallbacks() {
cancellables.removeAll()
}
}
Test the fix
func testCallbacksNotLeak() {
var viewController: PlaylistViewController? = PlaylistViewController()
viewController?.addUpdateCallback { _ in }
XCTAssert(viewController?.updateCallbacks.count ?? 0 > 0)
viewController?.updateCallbacks.removeAll()
viewController = nil
}
Pattern 4: Strong Reference Cycles (Closures + Properties)
❌ Leak — Two objects strongly reference each other
@MainActor
class Player: NSObject {
var delegate: PlayerDelegate?
var onPlaybackEnd: (() -> Void)?
init(delegate: PlayerDelegate) {
self.delegate = delegate
}
}
class PlaylistController: PlayerDelegate {
var player: Player?
override init() {
super.init()
self.player = Player(delegate: self)
player?.onPlaybackEnd = { [self] in
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
self?.playNextTrack()
}
}
deinit {
player?.onPlaybackEnd = nil
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()
customView.layoutIfNeeded = { [self] in
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
}
deinit {
customView?.delegate = nil
}
}
protocol CustomViewDelegate: AnyObject {
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.
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]
imageManager.requestImage(
for: asset,
targetSize: thumbnailSize,
contentMode: .aspectFill,
options: nil
) { [weak self] image, _ in
cell.imageView.image = image
}
return cell
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
}
}
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) {
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()
if imageRequestID != PHInvalidImageRequestID {
PHImageManager.default().cancelImageRequest(imageRequestID)
imageRequestID = PHInvalidImageRequestID
}
imageView.image = nil
}
deinit {
if imageRequestID != PHInvalidImageRequestID {
PHImageManager.default().cancelImageRequest(imageRequestID)
}
}
}
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
PHImageRequestID in cell (not in view controller)
- Cancel BEFORE starting new request (prevents request storms)
- Cancel in
prepareForReuse() (critical for collection views)
- Check
imageRequestID != PHInvalidImageRequestID before cancelling
Other async APIs with similar patterns
AVAssetImageGenerator.generateCGImagesAsynchronously() → call cancelAllCGImageGeneration()
URLSession.dataTask() → call cancel() on task
- Custom image caches → implement
invalidate() or cancel() 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
if peakMemory > 400_000_000 {
print("⚠️ High memory: \(peakMemory / 1_000_000)MB")
}
}
}
}
}
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
import os.log
let logger = os.log(subsystem: "com.yourapp.memory", category: "lifecycle")
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:
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
@Test func viewModelDeallocates() {
var vm: PlayerViewModel? = PlayerViewModel()
vm?.startPlayback(Track(id: "1", title: "Test"))
vm?.stopPlayback()
vm = nil
}
@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)
}
@Test func observerRemovedOnDeinit() {
var vc: DetailViewController? = DetailViewController()
let startCount = NotificationCenter.default.observers().count
_ = vc
vc = nil
XCTAssertEqual(NotificationCenter.default.observers().count, startCount)
}
@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)
}
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)
)
}
}
Command Line Tools for Memory Debugging
xcrun xctrace record --template "Memory" --output memory.trace
xcrun xctrace dump memory.trace
instruments -t "Leaks" -a YourApp -p 1234
xcrun simctl spawn booted launchctl list | grep memory
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() or cancel() on timers/subscribers
❌ Invalidating timer but keeping strong reference
timer?.invalidate()
timer?.invalidate()
timer = nil
❌ Assuming AnyCancellable auto-cleanup is automatic
func setupListener() {
let cancellable = NotificationCenter.default
.publisher(for: .myNotification)
.sink { _ in }
}
@MainActor
class MyClass: ObservableObject {
private var cancellables = Set<AnyCancellable>()
func setupListener() {
NotificationCenter.default
.publisher(for: .myNotification)
.sink { _ in }
.store(in: &cancellables)
}
}
❌ 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
deinit {
print("ViewModel deallocating")
}
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
xcodebuild build -scheme YourScheme
xcrun simctl launch booted com.your.bundleid
xcrun simctl openurl booted "debug://problem-screen"
sleep 1
/axiom:screenshot
Stress Testing with Screenshots
Test the screen that was leaking, repeatedly:
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
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 fix (timer cleanup added):
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