Memory management, ARC and performance
Strong, weak and unowned references, capture semantics, value-type copying, autorelease, and using Instruments to find a leak or a hot path.
ARC and reference cycles
final class Node {
let name: String
var parent: Node? // strong by default: a cycle with children
weak var delegate: NodeDelegate?
init(name: String) { self.name = name }
}
// the classic cycle: a closure stored on the object captures self
final class Loader {
var onFinish: (() -> Void)?
func begin() {
onFinish = { [weak self] in
guard let self else { return }
print(self.describe())
}
}
func describe() -> String { "loader" }
}
// unowned is safe only when the referent outlives the reference
final class Child {
unowned let parent: Parent
init(parent: Parent) { self.parent = parent }
}- A cycle is invisible to ARC. Two objects holding strong references to each other are never deallocated, and nothing warns you.
- A closure captures
selfstrongly by default. Use[weak self]for an escaping closure that may outlive the object. unownedavoids the optional unwrap but crashes if the referent is gone; use it for a parent that provably outlives the child.deinitis the cheapest leak detector: put a log line there and see whether a screen you navigated away from ever reaches it.
Value types and copying
// copy-on-write: assignment is cheap until a mutation happens
var first = Array(repeating: 0, count: 1_000_000)
var second = first // no copy yet, both share the buffer
second[0] = 1 // now the buffer is copied exactly once
print(first[0], second[0]) // 0 1
// a large struct passed around is copied unless the compiler proves otherwise,
// so build it once and return it rather than through several transformation steps
struct Report { let rows: [Row]; let totals: [String: Decimal] }
func makeReport(_ rows: [Row]) -> Report {
Report(rows: rows, totals: rows.reduce(into: [:]) { totals, row in
totals[row.category, default: 0] += row.amount
})
}| Cost | Cause | Fix |
|---|---|---|
| Repeated copying | Large structs passed through many calls | Pass by reference with inout or return once |
| Retain and release traffic | Many small class instances in a loop | Use value types |
| Main-thread hitch | Decoding or layout off the wrong queue | Move work off the main actor |
| Growing memory | A cache with no eviction | Bound the cache and evict |
Profiling with Instruments
# leaks and allocations while exercising the app
xcrun xctrace record --template "Leaks" \
--launch com.example.app --output leaks.trace --time-limit 90s
# what is burning CPU during a specific interaction
xcrun xctrace record --template "Time Profiler" \
--launch com.example.app --output cpu.trace --time-limit 60s
# memory graph in Xcode: Debug > Memory Graph while the suspicious screen is open💡
Profile a release build with debug symbols. Debug builds disable optimisations, keep bounds checks and slow generic code, so a hot spot found there may not exist in production and vice versa.
FAQ
Is <code>weak</code> always safer than <code>unowned</code>?
It is safer but costs an optional check and a side table entry. Use
weak when the referent may disappear, and unowned only for a relationship where lifetime is guaranteed by construction, such as a child holding its parent.Why does my app keep growing in memory?
Usually an unbounded cache, a retain cycle in a view controller graph, or images decoded at full resolution. The memory graph in Xcode shows exactly which object holds the strong reference that should not exist.
Related
Swift 6 concurrency: tasks, actors and Sendable Macros, property wrappers and result builders
Last refreshed 2026-09-18.