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
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
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
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.
These are real questions developers ask that this skill is designed to answer:
→ The skill covers systematic Instruments workflows to identify memory leaks vs normal memory pressure, with real diagnostic patterns
→ The skill distinguishes between progressive leaks (continuous growth) and temporary spikes (caches that stabilize), with diagnostic criteria
→ The skill demonstrates Memory Graph Debugger techniques to identify objects holding strong references and common retain cycle patterns
→ The skill covers the 5 diagnostic patterns, including specific timer and observer leak signatures with prevention strategies
→ The skill provides the Instruments decision tree to distinguish normal memory use, expected caches, and actual leaks requiring fixes
If you see ANY of these, suspect memory leak not just heavy memory use:
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
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)
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
✅ Object appears once
❌ Object appears 2+ times (means it's retained multiple times)
PlayerViewModel
↑ strongRef from: progressTimer
↑ strongRef from: TimerClosure [weak self] captured self
↑ CYCLE DETECTED: This creates a retain cycle!
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
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
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")
}
}
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
@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
}
}
ViewController → strongly retains ViewModel
↓
ViewModel → strongly retains Timer
↓
Timer → strongly retains closure
↓
Closure → captures [weak self] but still holds reference to Timer
self weakly BUT@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
}
}
@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
}
@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
}
}
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 upfunc 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
}
@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
}
}
@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
}
}
@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
}
@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
}
}
@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
}
}
ViewController
↓ strongly owns
updateCallbacks array
↓ contains
Closure captures self
↓ CYCLE
Back to ViewController (can't deallocate)
@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
}
}
@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()
}
}
@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
}
}
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
}
@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()
}
}
}
@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
}
}
@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()
}
}
}
@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)
}
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
}
}
Root cause PHImageManager.requestImage() returns a PHImageRequestID that must be explicitly cancelled. Without cancellation, pending requests queue up and hold memory.
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
}
}
PHImageRequestID in cell (not in view controller)prepareForReuse() (critical for collection views)imageRequestID != PHInvalidImageRequestID before cancellingAVAssetImageGenerator.generateCGImagesAsynchronously() → call cancelAllCGImageGeneration()URLSession.dataTask() → call cancel() on taskinvalidate() or cancel() methodChallenge Memory leak only happens with specific user data (large photo collections, complex data models) that you can't reproduce locally.
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
}
}
}
}
}
When user reports crash:
YourApp_2024-01-15-12-45-23)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:
After deploying fix:
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)
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
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.
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)
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
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)
// 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
)
}
}
# 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
❌ Using [weak self] but never calling invalidate()
invalidate() or cancel() 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
❌ Fixing the wrong leak first
❌ 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
| 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 |
Before 50+ PlayerViewModel instances created/destroyed
After Timer properly invalidated in all view models
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.
After fixing memory leaks, verify your app's UI still renders correctly and doesn't introduce visual regressions.
Memory fixes can sometimes break functionality:
Always verify:
# 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.)
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)
/axiom:test-simulator
Then describe:
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