Automatic reference counting

How Swift ARC governs class-instance lifetimes, and how strong, weak, and unowned references prevent or create reference cycles.

level intermediate time 11 min at Standard depth
version Swift 6.3.3
what

Automatic Reference Counting (ARC) manages a class instance’s lifetime from its strong references; after the last needed strong reference disappears, the instance can be deallocated.

trap

ARC does not detect strong reference cycles; two objects can own each other, or an object can own a closure that strongly captures it, even after external references disappear.

fix

Draw the ownership relationships first, then make a nonowning edge weak; use unowned only when a lifetime invariant guarantees that the target is always alive.

What it is and why it exists

Automatic Reference Counting (ARC) is Swift’s mechanism for managing class-instance lifetimes. The compiler inserts retain and release operations where they are needed, and the runtime uses them to maintain strong references; when an instance has no strong references that still need it, its deinit runs and its memory can be reclaimed. Application code normally does not call retain or release operations directly.

ARC solves the release-timing problem for shared reference objects. Several variables, properties, or closures can point to one class instance, so lexical scope alone cannot identify which user leaves last. Strong references express ownership and keep the instance alive until every owner is finished with it.

Reference counting applies only to class instances. Structures and enumerations are value types whose assignment and parameter passing follow value semantics; they can contain references internally, but the value itself is not managed by reference-counting that value. Closures are also reference types, and their capture contexts can retain class instances, so they affect object lifetimes too.

ARC does not traverse an object graph to collect unreachable cycles. If two instances are left with only strong references to each other, those strong references still exist and their counts do not reach a releasable state. This strong reference cycle is one of the most common sources of Swift memory leaks.

You meet ARC in bidirectional model relationships, delegates, parent-child objects, caches, subscriptions, and escaping closures. The useful question is not “is there a closure here?” but “who stores whom, and does each edge represent ownership?” Establish ownership before choosing a strong, weak, or unowned reference.

How it works

The default reference to a class instance is strong. Assigning an instance to another variable or strong property adds an ownership edge that keeps the target alive; clearing the variable, overwriting the property, or ending its required use removes the corresponding edge. Swift exposes no public reference count that application logic should depend on, so analyze edges instead of guessing a number at one instant.

A weak reference does not own its target and does not prevent that target from being deallocated. The weak reference automatically becomes nil when the target is deallocated, so weak must modify an optional var. A common relationship has a parent strongly own a child while the child only needs to refer back to its parent.

An unowned reference also does not own its target, but an ordinary unowned reference does not automatically become a checkable nil. It promises that the target is still alive whenever the reference is accessed; breaking that promise produces a runtime error. It suits relationships guaranteed by the model, such as a part that can never outlive its whole, not an attempt to avoid optional unwrapping.

A closure strongly captures a class instance it uses unless its capture list says otherwise. When an object strongly owns a closure property and the closure strongly captures that object, the two strong edges form a loop. [weak self] or [unowned self] changes the closure-to-instance edge; it does not change any other references in the object graph.

Solid lines in the diagram represent ownership, while dotted lines are nonowning relationships. An instance must stay alive while a long-lived root can still reach it through strong edges; weak and unowned edges do not extend the target’s lifetime. A cycle is not inherently wrong—an all-strong cycle that cannot be broken deliberately is the leak.

Choose a reference kind in this order:

  1. Should this reference own the target and keep it alive at least as long as the holder? If so, keep the default strong reference.
  2. Can the target be deallocated before the holder, and can the caller handle “target no longer exists”? If so, use weak.
  3. Does the model guarantee that the target is alive whenever the holder is alive? Use unowned only when this invariant holds.
  4. Is a closure stored by the instance, directly or indirectly? If so, follow both storage and capture edges to find a loop.

Examples

Strong references determine lifetime

The first example points two optional variables at the same subscription. Setting primary to nil removes only one strong edge; backup still owns the instance, so access remains valid. The deinit runs only after the final strong edge is cleared.

strong_references.swift
final class Subscription {
    let channel: String

    init(channel: String) {
        self.channel = channel
        print("created:", channel)
    }

    deinit {
        print("released:", channel)
    }
}

func demonstrateStrongReferences() {
    var primary: Subscription? = Subscription(channel: "news")
    var backup = primary

    primary = nil
    print("active:", backup?.channel ?? "none")

    backup = nil
}

demonstrateStrongReferences()
created: news
active: news
released: news

There is no need—and no reliable facility—to read a “current count” here. Knowing which variables form strong edges explains the output. Explicitly assigning nil makes the release point observable in this teaching example; real programs should follow resource ownership instead of expecting every closing brace to trigger deinit at that exact point.

Break a bidirectional relationship with weak

The account owns its session, so Account.session is strong. A session only needs to know its current account and is not responsible for keeping that account alive, so the reverse property is weak. After the account is deallocated, the property automatically becomes nil, while the local variable keeps the session alive until the end.

weak_back_reference.swift
final class Account {
    let name: String
    var session: Session?

    init(name: String) {
        self.name = name
    }

    deinit {
        print("released account:", name)
    }
}

final class Session {
    let token: String
    weak var account: Account?

    init(token: String) {
        self.token = token
    }

    deinit {
        print("released session:", token)
    }
}

func demonstrateWeakReference() {
    var account: Account? = Account(name: "Mina")
    var session: Session? = Session(token: "S-42")

    account?.session = session
    session?.account = account
    account = nil

    print("owner missing:", session?.account == nil)
    session = nil
}

demonstrateWeakReference()
released account: Mina
owner missing: true
released session: S-42

Changing Session.account to a strong property would make the two property edges a cycle. After the local variables were cleared, the account would still be owned by the session and the session would still be owned by the account, so neither deinit would run. Marking the nonownership direction as weak makes the code match the domain relationship.

Express a strict invariant with unowned

A customer owns a membership card, and the card has no meaning without its customer. MembershipCard.customer can be unowned if no code can allow the card to outlive the customer. This example first releases the local card reference, while the customer still strongly owns the card; releasing the customer then ends both lifetimes.

unowned_invariant.swift
final class Customer {
    let name: String
    var card: MembershipCard?

    init(name: String) {
        self.name = name
    }

    deinit {
        print("released customer:", name)
    }
}

final class MembershipCard {
    let suffix: String
    unowned let customer: Customer

    init(suffix: String, customer: Customer) {
        self.suffix = suffix
        self.customer = customer
    }

    deinit {
        print("released card:", suffix)
    }
}

func demonstrateUnownedReference() {
    var customer: Customer? = Customer(name: "Priya")
    var card: MembershipCard? = MembershipCard(
        suffix: "4242",
        customer: customer!
    )

    customer?.card = card
    print("owner:", card?.customer.name ?? "none")

    card = nil
    print("card retained by customer:", customer?.card != nil)
    customer = nil
}

demonstrateUnownedReference()
owner: Priya
card retained by customer: true
released customer: Priya
released card: 4242

If outside code can retain a MembershipCard independently, this invariant does not hold; use weak var customer: Customer? and handle a missing customer instead. unowned is an executable lifetime contract, not a style choice. During review, look for ways that a “part” can be extracted from its “whole” and stored for longer.

Repair a closure cycle with a capture list

The object stores a closure in onRefresh, which is a strong edge from the object to the closure. The capture list gives the closure only a weak reference back, so retaining the callback elsewhere does not extend the Dashboard lifetime. Calling the callback again after deallocation takes the missing-object branch instead of accessing a dangling instance.

weak_capture.swift
final class Dashboard {
    let name: String
    var onRefresh: (() -> Void)?

    init(name: String) {
        self.name = name
    }

    func configureRefresh() {
        onRefresh = { [weak self] in
            guard let self else {
                print("skipped: dashboard released")
                return
            }
            print("refresh:", self.name)
        }
    }

    deinit {
        print("released dashboard:", name)
    }
}

func demonstrateWeakCapture() {
    var dashboard: Dashboard? = Dashboard(name: "sales")
    dashboard?.configureRefresh()
    var callback = dashboard?.onRefresh

    callback?()
    dashboard = nil
    callback?()
    callback = nil
}

demonstrateWeakCapture()
refresh: sales
released dashboard: sales
skipped: dashboard released

If a closure exists only during a synchronous call and no path stores it back under Dashboard, strongly capturing self does not automatically create a cycle. Choose a weak capture from the closure’s storage location and the intended behavior. A strong capture can be correct when the operation must finish.

Pitfalls

Fix: first establish who stores the closure and whether the work must complete. A strong capture need not form a cycle when the closure is not reachable from self; if a task must own its executor until completion, capture strongly and provide cancellation explicitly. A weak capture expresses the right semantics only when the work should be abandoned after the object disappears.

Fix: decide whether the whole operation needs consistent ownership or each step may tolerate the object’s disappearance. The former can hold strongly and say so; the latter should narrow the strong reference’s scope, copy only the immutable data needed for one step, and recheck cancellation and target existence after suspension points.

Fix: require the review to name the precise invariant supporting unowned and every API that can retain the holder or target independently. If you cannot prove that the target always lives longer, use weak and handle nil. Do not substitute minor syntactic convenience for a lifetime proof.

Fix: review a method reference as a closure that captures its receiver. Follow the storage owner’s properties back to the receiver and look for a loop; when the relationship should be nonowning, write a wrapper closure with [weak self] and define what happens after the target disappears.

Fix: disconnect ownership edges in cleanup that covers every termination path. Test successful completion, failure, and cancellation with a deinit signal or weak probe, and confirm that restarting replaces or releases an old callback instead of accumulating storage owners.

Deep Deriving lifetime from the ownership graph

Deriving lifetime from the ownership graph

Reference counting is the implementation mechanism; an ownership graph is the more useful review model. Nodes are class instances, closures, or storage for other reference types, and strong edges mean that the source keeps the target alive. Weak and unowned edges permit access without ownership. Local variables, collection elements, properties, tasks, and subscriptions can all be sources of strong edges.

Start at roots still owned by the process, framework, or active execution stack and follow only strong edges. Nodes reachable from a root must stay alive; nodes unreachable from roots but locked in a strong cycle are not automatically collected by ARC either. To break a cycle, remove or weaken one edge that does not represent ownership rather than making every edge in the cycle weak.

Ownership and accessibility are different questions. A weak reference permits temporary access without promising that the target survives, an unowned reference permits direct access while putting that promise on the programmer, and only a strong reference extends lifetime. Keeping those questions separate prevents “I need to call it” from becoming “I must own it.”

Choosing strong, weak, or unowned

ReferenceOwns targetAfter target deallocationProperty shapeRequired premise
strongYesTarget stays alive while heldDefault let or varHolder controls target lifetime
weakNoAutomatically becomes nilOptional varTarget may leave first; absence is handled
unownedNoLater access failsUsually nonoptional let or varTarget is guaranteed to outlive the referrer

The table describes semantics, not a performance ranking. Strong is the default and most common choice; weak and unowned exist only to express nonownership. In a bidirectional relationship, an aggregate root often strongly owns a part while the part weakly refers to the root, but a domain model can require the opposite direction.

A weak property must be a var because ARC needs to write nil when the target is deallocated. It must be optional because “target has disappeared” is a normal representable state. Bind the optional into one local scope before use; do not check it for non-nil and then reread the weak property, because the target can end its lifetime between those reads.

An ordinary unowned reference omits the absent state without extending the target’s lifetime. It fits an invariant maintained jointly by construction and encapsulation, such as a part stored only inside its whole when no API hands that part to a longer-lived owner. Once a type permits independent caching or delayed execution, weak is usually more honest.

Swift also supports optional unowned references and more specialized forms such as unowned(unsafe). They are not substitutes for clear ownership design; unowned(unsafe) in particular removes the runtime checks of an ordinary unowned reference and does not belong in general application examples. Consider it only for low-level interoperability backed by a separate safety argument.

Capture lists take effect at closure creation

Capture-list entries are initialized when the closure is created. [snapshot = value] stores the value obtained at that time; if the value is a class instance and neither weak nor unowned appears, that storage is still a strong reference. A capture list is not syntax for automatically copying a whole object.

[weak self] creates a weak reference to the current instance, so the closure body sees optional self. A successful guard let self establishes a new strong local reference, and its scope determines how long the instance’s lifetime is extended. This weak-to-strong transition is often correct, but it must be reviewed alongside suspension points and cancellation semantics.

[unowned self] assumes that the instance is alive every time the closure runs. If the instance owns the closure and the closure never escapes, their lifetimes may be coupled; as soon as the callback can be copied elsewhere, queued for delayed execution, or stored by another object, the premise needs a new proof. The closure’s type usually does not prevent it from escaping to a longer lifetime.

Capturing a value type can change behavior too. A capture list stores the creation-time value, whereas directly referring to an outer mutable variable can observe shared capture storage. When reviewing ARC, distinguish both “which entity is captured” and “with which ownership”; otherwise a cycle fix can introduce a stale snapshot.

A capture list controls only the edges it names. [weak self] can still leave a different strong loop when the closure reaches self indirectly through another object, or captures a service that itself strongly owns self. A complete review expands the references held by captured values too.

What ARC does not manage

ARC manages the lifetime of Swift references, but it does not automatically close a file descriptor, socket, or database transaction. A class can perform final cleanup in deinit, yet business correctness should not depend only on exactly when an object happens to deallocate. A resource with a required ending point needs an explicit close, cancel, or scoped API.

Reference cycles are not the only cause of memory growth. An unbounded cache, an ever-growing callback array, and tasks that are still running can all remain legitimately reachable from a root through strong edges, so ARC correctly retains them. Fix capacity or cancellation policy in that case instead of adding weak references blindly.

Value types are not reference-counted as values, but that does not mean they never use heap memory or participate in reference lifetimes. Values such as arrays and strings can use shared storage and copy-on-write, and a structure property can strongly own a class instance. When checking whether an object deallocates, follow reference fields inside structures instead of reading “value type” as “no memory management.”

The compiler can arrange releases around the final use, so deinit should not be treated as an ordinary control-flow callback. Exact log ordering can change with optimization, concurrency, and framework retention. Tests should assert eventual release and explicit cleanup of critical resources rather than depend on an incidental instantaneous count.

Verify release with falsifiable tests

deinit logging works for small examples and debugging, but a weak probe is more useful in a larger test. Build the object graph, observe the target through a local weak variable, clear external strong references, and then assert that the probe eventually becomes nil. If tasks or queues are involved, drive their cancellation and completion boundaries first.

Exercise normal completion, thrown errors, early returns, and cancellation. Every path must release one-shot callbacks, observer tokens, and subscriptions, and repeated registration needs defined replacement behavior. Happy-path tests cannot expose the most common cleanup gaps.

A memory-graph tool can show why an instance is still reachable, but a tool’s “potential leak” still needs interpretation through domain ownership. Find the strong path from a root to the target before deciding which edge should not own it. Making the first property on that path weak can instead cause premature deallocation.

A test should also retain an external copy of a callback before releasing the original object. This distinguishes a cycle caused by the object storing its own closure from safe invalidation after the closure escapes, and it exposes an unowned crash that happens only on delayed invocation.

The final review question is not “does this use ARC?” because Swift uses ARC by default. Verify that the ownership graph matches the domain, that nonowning edges choose the right invalidation behavior, and that every long-lived storage edge has a clear disconnection condition.

Further reading

checkpoint

5 questions · 2 predict-the-output · 1 spot-the-bug

next up Protocols Combine soon Concurrency soon Swiftui state soon
Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?