From axiom
Detects and fixes memory leaks in iOS/macOS apps using Instruments, Memory Graph Debugger, deinit logging, and Jetsam analysis. For memory warnings, retain cycles, or crashes after prolonged use.
npx claudepluginhub charleswiltgen/axiom --plugin axiomThis skill uses the workspace's default tool permissions.
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.
Provides Ktor server patterns for routing DSL, plugins (auth, CORS, serialization), Koin DI, WebSockets, services, and testApplication testing.
Conducts multi-source web research with firecrawl and exa MCPs: searches, scrapes pages, synthesizes cited reports. For deep dives, competitive analysis, tech evaluations, or due diligence.
Provides demand forecasting, safety stock optimization, replenishment planning, and promotional lift estimation for multi-location retailers managing 300-800 SKUs.
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.
Leak vs normal: Normal = stays at 100MB. Leak = 50MB → 100MB → 150MB → 200MB → CRASH.
ALWAYS diagnose FIRST (before reading code):
What this tells you: Flat = not a leak. Linear growth = classic leak. Spike then flat = normal cache. Spikes stacking = compound leak.
Why diagnostics first: Finding leak with Instruments: 5-15 min. Guessing: 45+ min.
Key instruments: Heap Allocations (object count), Leaked Objects (direct detection), VM Tracker (by type).
// Add deinit logging to suspect classes
class MyViewController: UIViewController {
deinit { print("✅ MyViewController deallocated") }
}
@MainActor
class ViewModel: ObservableObject {
deinit { print("✅ ViewModel deallocated") }
}
Navigate to view, navigate away. See "✅ deallocated"? Yes = no leak. No = retained somewhere.
Jetsam is not a bug — iOS terminates background apps to free memory. Not a crash (no crash log), but frequent kills hurt UX.
| Termination | Cause | Solution |
|---|---|---|
| Memory Limit Exceeded | Your app used too much memory | Reduce peak footprint |
| Jetsam | System needed memory for other apps | Reduce background memory to <50MB |
Clear caches on backgrounding:
// SwiftUI
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
imageCache.clearAll()
URLCache.shared.removeAllCachedResponses()
}
}
Users shouldn't notice jetsam. Use @SceneStorage (SwiftUI) or stateRestorationActivity (UIKit) to restore navigation position, drafts, and scroll position.
class JetsamMonitor: NSObject, MXMetricManagerSubscriber {
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
guard let exitData = payload.applicationExitMetrics else { continue }
let bgData = exitData.backgroundExitData
if bgData.cumulativeMemoryPressureExitCount > 0 {
// Send to analytics
}
}
}
}
App memory grows while in USE? → Memory leak (fix retention)
App killed in BACKGROUND? → Jetsam (reduce bg memory)
Why [weak self] alone doesn't fix timer leaks: The RunLoop retains scheduled timers. [weak self] only prevents the closure from retaining self — the Timer object itself continues to exist and fire. You must explicitly invalidate() to break the RunLoop's retention.
progressTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.updateProgress()
}
// Timer never stopped → RunLoop keeps it alive and firing forever
cancellable = Timer.publish(every: 1.0, tolerance: 0.1, on: .main, in: .default)
.autoconnect()
.sink { [weak self] _ in self?.updateProgress() }
// No deinit needed — cancellable auto-cleans when released
Alternative: Call timer?.invalidate(); timer = nil in both the appropriate teardown method (viewWillDisappear, stop method, etc.) AND deinit.
For timer crash patterns (EXC_BAD_INSTRUCTION) and RunLoop mode issues, see
axiom-timer-patterns.
NotificationCenter.default.addObserver(self, selector: #selector(handle),
name: AVAudioSession.routeChangeNotification, object: nil)
// No matching removeObserver → accumulates listeners
NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification)
.sink { [weak self] _ in self?.handleChange() }
.store(in: &cancellables) // Auto-cleanup with viewModel
Alternative: NotificationCenter.default.removeObserver(self) in deinit.
updateCallbacks.append { [self] track in
self.refreshUI(with: track) // Strong capture → cycle
}
updateCallbacks.append { [weak self] track in
self?.refreshUI(with: track)
}
Clear callback arrays in deinit. Use [unowned self] only when certain self outlives the closure.
player?.onPlaybackEnd = { [self] in self.playNextTrack() }
// self → player → closure → self (cycle)
player?.onPlaybackEnd = { [weak self] in self?.playNextTrack() }
Use the delegation pattern with AnyObject protocol (enables weak references) instead of closures that capture view controllers.
PHImageManager.requestImage() returns a PHImageRequestID that must be cancelled. Without cancellation, pending requests queue up and hold memory when scrolling.
class PhotoCell: UICollectionViewCell {
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
}
}
Similar patterns: AVAssetImageGenerator → cancelAllCGImageGeneration(), URLSession.dataTask() → cancel().
Profile with Memory template, repeat action 10 times. Flat = not a leak (stop). Steady climb = leak (continue).
Memory Graph Debugger → purple/red circles → click → read retain cycle chain.
Common locations: Timers (50%), Notifications/KVO (25%), Closures in collections (15%), Delegate cycles (10%).
Apply fix from patterns above. Add deinit { print("✅ deallocated") }. Run Instruments again — memory should stay flat.
Real apps often have 2-3 leaks stacking. Fix the largest first, re-run Instruments, repeat until flat.
When Instruments prevents reproduction (Heisenbug) or leaks only happen with specific user data:
Lightweight diagnostics (when Instruments can't be attached):
deinit { print("✅ ClassName deallocated") } to all suspect classes. Run 20+ sessions. When the leak occurs (e.g., 1 in 5 runs), missing deinit messages reveal which objects are retained.MXMetricPayload.memoryMetrics.peakMemoryUsage. Alert when exceeding threshold (e.g., 400MB). This catches leaks that only manifest with real user data volumes.Common cause of intermittent leaks: Notification observers added on lifecycle events (viewWillAppear, applicationDidBecomeActive) without removing duplicates first. Each re-registration accumulates a listener — timing determines whether the duplicate fires.
TestFlight verification: Ship diagnostic build to affected users. Add os_log memory milestones. Monitor MetricKit for 24-48 hours after fix deployment.
invalidate() or cancel()timer?.invalidate() stops firing but reference remains. Always follow with timer = nilSet<AnyCancellable> property| 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 | Objects consuming most memory |
| Cache behavior | Allocations | Objects allocated but not freed |
Xcode ships CLI tools for fast memory diagnostics without opening Instruments. Use these for quick checks during development.
# Check running app by name (positional argument, not --process)
xcrun leaks MyApp
# Check by PID
xcrun leaks 12345
# Show full stack traces for each leak
xcrun leaks --fullStacks MyApp
# Analyze a memgraph file (from Xcode's Debug Memory Graph)
xcrun leaks MyApp.memgraph
When to use: Quick leak check without recording an Instruments trace. Run after exercising a suspect code path.
# Show heap summary by class (process name is positional)
xcrun heap MyApp
# Show all instances of a specific class
xcrun heap --addresses=MyViewController MyApp
# Sort by size (find biggest consumers)
xcrun heap -s MyApp
# Analyze a memgraph
xcrun heap MyApp.memgraph
When to use: Finding what's consuming memory right now. Answers "how many MyViewController instances exist?" without Instruments.
# Summary view (dirty, clean, swapped)
xcrun vmmap --summary MyApp.memgraph
# Full memory regions
xcrun vmmap MyApp.memgraph
When to use: Understanding memory composition. Shows dirty pages (your data), clean pages (mapped files), and compressed memory.
# Find duplicate strings in running process (positional argument)
xcrun stringdups MyApp
# Analyze a memgraph
xcrun stringdups MyApp.memgraph
When to use: Reducing memory footprint from repeated string allocations. No GUI equivalent.
# Enable malloc logging first: set MallocStackLogging=1 in scheme env vars
# Then query a specific address
xcrun malloc_history <pid> <address>
# Show all allocations sorted by size
xcrun malloc_history <pid> -allBySize
When to use: Tracing where a leaked object was allocated. Requires MallocStackLogging=1 environment variable in scheme.
# 1. Is there a leak? (30 seconds)
xcrun leaks MyApp
# 2. What's on the heap? (30 seconds)
xcrun heap -s MyApp
# 3. Any duplicate strings wasting memory? (30 seconds)
xcrun stringdups MyApp
# 4. Where is memory allocated? (requires memgraph)
xcrun vmmap --summary MyApp.memgraph
Time cost: 2 minutes for a full CLI memory check vs 10+ minutes launching Instruments.
# Record memory trace without GUI
xcrun xctrace record --instrument 'Allocations' --attach 'MyApp' --time-limit 30s --output memory.trace
# Record leak detection
xcrun xctrace record --instrument 'Leaks' --attach 'MyApp' --time-limit 30s --output leaks.trace
Before: 50+ PlayerViewModel instances with uncleared timers → 50MB → 200MB → Crash (13min) After: Timer properly invalidated → 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.
WWDC: 2021-10180, 2020-10078, 2018-416
Docs: /xcode/gathering-information-about-memory-use, /metrickit/mxbackgroundexitdata
Skills: axiom-performance-profiling, axiom-objc-block-retain-cycles, axiom-metrickit-ref, axiom-lldb (inspect retain cycles interactively)