iOS Memory Management in Swift 6: ARC, Actors, and Leaks That Survive Code Review
- Author
- Ehsan Azish · 3NSOFTS
- Updated
- July 2026
- Read time
- 12 min read
- Level
- Intermediate
- Platform
- Swift 6, ARC fundamentals, async/await, SwiftUI lifecycle basics
Implementation Notes
- ~/ What broke: A production edge case that generic tutorials skip.
- ~/ What to do: Ship the production fix with clear state, errors, and fallback behavior.
Swift 6 strict concurrency changed a lot. But it didn't fix your retain cycles. ARC still works exactly the way it always has, and the leaks that slip past code review in 2026 are subtler than a forgotten [weak self] in a closure. They hide inside actors, SwiftUI view models, Combine pipelines, and async task trees.
This article covers the memory management patterns that matter most in production Swift 6 code: how ARC works at the model level, where actors introduce new ownership complexity, and which leak categories survive even careful review.
How ARC Works in Swift 6
Automatic Reference Counting hasn't changed mechanically. Every class instance carries a reference count. When that count drops to zero, the deinitializer runs and memory is freed. Value types — structs, enums, tuples — aren't reference-counted at all.
What Swift 6 changed is the concurrency model around those references. Strict concurrency checking means the compiler now enforces isolation boundaries at compile time. That's a good thing. But it also means you can create new ownership patterns that ARC can't see, and the compiler won't warn you about them.
Strong, Weak, and Unowned — Still the Foundation
The three reference qualifiers are unchanged:
strong— the default; increments the retain countweak— does not retain; automatically becomesnilwhen the referent deallocates; alwaysOptionalunowned— does not retain; crashes if accessed after the referent deallocates; use only when you're certain the lifetimes are tied
The classic retain cycle is two objects holding strong references to each other — neither can deallocate, and you break it with weak on one side. Every iOS developer knows this. The leaks that survive code review aren't this simple.
Where Leaks Actually Hide in Swift 6 Code
Closures Capturing Self in Async Contexts
Before Swift 6, the most common leak was a closure stored on an object that also captured self strongly. Swift 6 strict concurrency adds a new wrinkle: @Sendable closures passed to Task { } or Task.detached { } capture self strongly by default, and that task can outlive the object that created it.
class ViewModel: ObservableObject {
var task: Task<Void, Never>?
func load() {
task = Task {
await self.fetchData() // strong capture
}
}
}
If ViewModel is dismissed while task is still running, self stays alive until the task completes. The fix isn't always [weak self] — sometimes you want the task to finish. The real question is whether you cancel it in deinit or when the view disappears.
deinit {
task?.cancel()
}
Cancel in deinit if the work is tied to the object's lifetime. Cancel on onDisappear if it's tied to the view's lifetime. These are different decisions.
Actors and the Retain Cycle You Cannot See
Actors are reference types. That means they participate in ARC exactly like classes. An actor that holds a strong reference to another actor, which holds a delegate or callback back to the first, creates a cycle.
actor DataStore {
var delegate: DataStoreDelegate? // strong by default
}
class ViewModel: DataStoreDelegate {
let store = DataStore()
// store holds self strongly via delegate
// self holds store strongly
// cycle
}
The compiler doesn't warn about this. The delegate protocol doesn't force AnyObject conformance unless you write it. The fix:
protocol DataStoreDelegate: AnyObject { ... }
// then in actor:
weak var delegate: (any DataStoreDelegate)?
This pattern is easy to miss in review because actors feel safer than classes. They're isolated — but they're not leak-proof.
@MainActor ViewModels Retained by SwiftUI
SwiftUI manages view lifetimes in ways that aren't always obvious. An @StateObject is owned by the view's storage and lives as long as the view is in the hierarchy. An @ObservedObject is not owned by the view — it's passed in. If you pass an @ObservedObject into a child view and that child stores a closure referencing the parent's view model, you have a cycle that SwiftUI's diffing engine won't break for you.
The pattern to watch:
struct ParentView: View {
@StateObject var vm = ParentViewModel()
var body: some View {
ChildView(onAction: { [weak vm] in // capture vm weakly
vm?.handleAction()
})
}
}
Without [weak vm], the closure passed to ChildView holds vm strongly. If ChildView stores that closure in a property, and ParentViewModel holds a reference to ChildView's state, you have a cycle.
Combine Pipelines Without Cancellation
Combine subscriptions stored in AnyCancellable sets are cancelled when the set deallocates. But if the publisher outlives the subscriber and the subscriber is captured in a sink closure, the subscriber stays alive.
class ViewModel {
var cancellables = Set<AnyCancellable>()
func bind(to store: DataStore) {
store.publisher
.sink { [weak self] value in
self?.update(value)
}
.store(in: &cancellables)
}
}
The [weak self] in sink is correct here. What's less obvious: if store.publisher is a PassthroughSubject held by store, and store is also held by ViewModel, and ViewModel is held by the view — the chain is fine as long as the view deallocates. But if you store store as a singleton or inject it via environment, the view model may outlive the view and hold references you didn't plan for.
Task Groups and Structured Concurrency
Swift 6 structured concurrency with withTaskGroup and withThrowingTaskGroup manages child task lifetimes automatically — child tasks are cancelled when the group scope exits. That's correct behavior.
The leak risk is Task.detached. A detached task isn't bound to any scope. It runs until it completes or is explicitly cancelled, and if it captures a reference to an object, that object can't deallocate until the task finishes.
Use Task.detached only when you explicitly want work to outlive the current scope. For everything else, use structured concurrency or a task stored on the owning object.
Instruments: What to Actually Measure
The Leaks instrument in Xcode finds objects with no root reference that haven't been deallocated — true leaks. It doesn't find retain cycles where both sides are still reachable. For cycles, use the Allocations instrument with the "Mark Generation" workflow: allocate, navigate away, mark, navigate back, mark again. Objects that appear in the second generation but not the first are candidates for investigation.
The VM Tracker instrument shows dirty memory by region. If your app's dirty memory grows monotonically across navigation events, you have either a cycle or a cache without an eviction policy.
For async code, the Swift Concurrency instrument (available since Xcode 14) shows task lifetimes. A task that should have ended when its view disappeared but is still running is a strong indicator of a missing cancellation.
Swift 6 Strict Concurrency and Memory
Swift 6's Sendable checking doesn't prevent retain cycles — it prevents data races. These are different problems. A Sendable type can still participate in a retain cycle; the compiler just guarantees it can be safely passed across concurrency boundaries.
Where strict concurrency does help: it forces you to be explicit about ownership across isolation boundaries. When you annotate a closure as @Sendable, the compiler checks that every captured value is Sendable. That discipline often surfaces implicit strong captures you would have missed.
The Swift 6 AI Integration Guide at 3nsofts.com covers how these concurrency rules interact with on-device AI workloads specifically — particularly around LanguageModelSession lifecycle and actor-isolated model state.
The Leaks That Survive Code Review
A direct list of patterns that reviewers consistently miss:
1. Notification center observers not removed
NotificationCenter.default.addObserver with a closure retains the observer block. If you use the closure-based API and don't store the returned token — or store it but never call removeObserver — the block lives forever.
2. Timer strong references
Timer.scheduledTimer retains its target. A Timer that fires repeatedly and is never invalidated keeps its target alive. Invalidate in deinit or when the timer is no longer needed.
3. URLSession delegate
URLSession holds a strong reference to its delegate. If your view model is the delegate and you create a session in init, the session keeps the view model alive even after the view is gone. Use URLSession.shared for simple requests, or invalidate the session explicitly.
4. Delegate properties without weak
Any protocol property not marked weak is a strong reference. This is the most common review miss. Every delegate property on a class or actor should be weak unless you have a documented reason otherwise.
5. SwiftData model context retained in closures
ModelContext from SwiftData is not Sendable. Capturing it in an async closure that escapes the current isolation domain will produce a compiler error in Swift 6 — which is correct behavior. But if you capture the model context's parent container instead, and that container is a singleton, you can hold references longer than intended.
The DevScope Swift 6 performance case study shows how strict concurrency enforcement surfaced several of these patterns in a real production codebase.
A Note on Production Codebases
Memory management issues are among the top findings in codebase audits ahead of technical due diligence. They're rarely catastrophic in isolation — they show up as memory pressure warnings, jetsam logs, and thermal throttling on older devices. They're also the kind of problem that a new engineer inheriting the codebase will spend weeks diagnosing if it isn't documented.
If you're preparing for a Series A or onboarding new engineers, surfacing these patterns before they compound is worth the time. The AI-Native App Architecture Audit at 3nsofts.com covers architecture, AI readiness, and App Store compliance — and memory ownership patterns are a standard part of the architecture review.
FAQs
Does Swift 6 fix retain cycles automatically?
No. Swift 6 strict concurrency prevents data races by enforcing isolation boundaries at compile time. It doesn't detect or break retain cycles. ARC still requires you to manage ownership explicitly using weak and unowned references wherever cycles are possible.
When should I use unowned instead of weak?
Only when you're certain the referenced object will always outlive the referencing one. If there's any doubt, use weak. A weak reference becoming nil is a safe no-op. An unowned reference accessed after deallocation is a crash.
How do actors affect memory management compared to classes?
Actors are reference types and participate in ARC the same way classes do — they can form retain cycles. The difference is that actors enforce serial access to their state, which prevents data races but doesn't prevent cycles. Treat actor delegate properties the same as class delegate properties: mark them weak.
What is the fastest way to find a retain cycle in Xcode? Use the Allocations instrument with the Mark Generation workflow. Navigate to a screen, mark a generation, navigate away, mark again. Any objects from the first mark that appear in the second generation without a clear reason are cycle candidates. The Memory Graph Debugger in Xcode (the three-circle icon in the debug bar) also shows the reference graph for live objects.
Does Task { } create a retain cycle if it captures self?
It creates a strong capture, which keeps self alive until the task completes. That's not a cycle in the traditional sense, but it can delay deallocation significantly. Cancel the task in deinit if the work should stop when the object is released.
Are SwiftData model contexts safe to capture in async closures?
ModelContext is not Sendable, so Swift 6 will reject captures that cross isolation boundaries. The safe pattern is to pass the model container (which is Sendable) across boundaries and create a new context on the receiving actor.
How often do memory leaks appear in production iOS codebases? Frequently enough that they show up in most codebase audits. They're rarely catastrophic in isolation, but they compound. An app that leaks view models on every navigation event will hit memory pressure warnings on devices with 3–4 GB RAM within a typical user session.
Memory management in Swift 6 isn't harder than it was in Swift 5 — it's different. The concurrency model introduces new ownership patterns that ARC can't reason about automatically. The leaks that survive code review are the ones that look correct in isolation but create cycles across async boundaries, actor references, and SwiftUI ownership chains. Know the patterns, instrument regularly, and cancel your tasks.