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 self strongly by default. Use [weak self] for an escaping closure that may outlive the object.
  • unowned avoids the optional unwrap but crashes if the referent is gone; use it for a parent that provably outlives the child.
  • deinit is 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
    })
}
CostCauseFix
Repeated copyingLarge structs passed through many callsPass by reference with inout or return once
Retain and release trafficMany small class instances in a loopUse value types
Main-thread hitchDecoding or layout off the wrong queueMove work off the main actor
Growing memoryA cache with no evictionBound 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.

Swift 6 concurrency: tasks, actors and Sendable Macros, property wrappers and result builders

Last refreshed 2026-09-18.