Swift ARC: From Zombie Objects to Side Tables


Greetings, traveler!

Reference counting is one of those topics every iOS developer learns early and then rarely revisits in depth. At the surface, it feels predictable: objects are retained, released, and eventually deallocated. Underneath, the implementation is far more nuanced. The way Swift manages memory has changed since its early versions, and those changes reflect real trade-offs between performance, memory usage, and safety.

Understanding these mechanics changes how you reason about object lifetimes, why certain patterns behave the way they do, and where subtle performance costs come from. In this article, we will walk through how Swift’s reference counting evolved, from its original design to the modern runtime, and what that means for everyday code.

Early Swift: inline strong and weak counts, and zombie objects

In the first versions of Swift, reference counting followed a surprisingly compact design. Each object carried both strong and weak counts directly within its own memory. There was no separate structure to track weak references, no global registry, and no per-reference bookkeeping. The object itself was the single source of truth for how many references existed and of what kind.

Incrementing or decrementing a strong reference meant updating a counter stored alongside the object. Weak references were treated similarly, contributing to a separate count that lived in the same place. There was no additional level of indirection when accessing a weak reference. It was just a pointer to the object, with the runtime deciding what to do when that pointer was used.

The interesting part appeared when the last strong reference was released. If no weak references remained, the object could be destroyed and its memory reclaimed immediately. If weak references still existed, the runtime took a different path. The object was deinitialized, but its memory stayed allocated. In effect, it became a “zombie”: its logical lifetime had ended, yet its storage remained because weak references still pointed to it.

Accessing a weak reference triggered a check against this state. If the object had already been deinitialized, the runtime would clear the reference and return nil. At the same time, it would decrement the weak count. Only when the last weak reference had been observed and released could the memory finally be freed. Until then, the object lingered in memory in this intermediate state.

This approach had a few appealing properties. It avoided the need to track individual weak reference locations. There was no list of weak pointers to update during deallocation, which simplified the implementation and reduced coordination overhead. It also meant that accessing a weak reference did not require extra pointer chasing. In the common case, it was as direct as accessing a strong reference.

Why the old design had to change

The original approach worked well in simple scenarios. It kept the fast path lean, avoided global coordination, and relied on a small amount of metadata stored directly with the object. Over time, its limitations became harder to ignore.

The most visible issue was memory behavior. When the last strong reference disappeared, an object could remain allocated as long as weak references still existed. For small objects this was rarely noticeable. For larger instances, especially those with substantial inline storage, it meant that memory could be held longer than expected. In systems where object lifetimes were less predictable, this translated into gradual and sometimes surprising memory growth.

The second concern came from concurrency. Weak references were cleared lazily, at the moment they were accessed. That placed more responsibility on runtime checks during reads, and made it harder to reason about behavior under concurrent access. Multiple threads interacting with the same object could observe different states depending on timing, which increased the complexity of ensuring correctness. While these issues could be addressed with additional safeguards, they pointed to a deeper problem: the model itself did not scale cleanly under heavy multithreaded use.

There was also a broader architectural consideration. The design assumed that all objects should pay the cost of storing weak-related metadata inline, even though many objects would never be referenced weakly. As the language and its ecosystem evolved, that trade-off became less attractive. A more flexible approach would allow objects to remain lightweight by default and only incur additional overhead when specific features were used.

Taken together, these factors pushed the implementation toward a different direction. The goal was to preserve the efficiency of the common case while improving memory behavior, strengthening guarantees under concurrency, and avoiding unnecessary overhead for objects that did not use weak references.

Swift 4 and beyond: side tables and the new weak model

The revised design introduced an important shift in how object metadata is stored. Instead of forcing every object to carry all reference-counting state inline, the runtime adopted an optional external structure often referred to as a side table. This structure is allocated only when the object needs functionality that does not belong on the fast path.

In the common case, nothing changes. A newly created object still keeps its reference counts directly in its header. Strong and unowned references operate on this inline storage, and the object remains as compact and efficient as before. As long as no weak references are involved, the runtime avoids any additional allocation or indirection.

The behavior changes the moment a weak reference is introduced. At that point, the runtime creates a side table and moves the reference-counting data into it. The field that previously stored inline counts is repurposed to hold a pointer to this external structure. From then on, all reference-counting operations for that object go through the side table.

Weak references also change their target. Instead of pointing directly to the object, they point to the side table. This indirection allows the runtime to separate the lifetime of the object from the lifetime of the weak reference bookkeeping. When the last strong reference is released, the object can be deinitialized and its memory reclaimed immediately. The side table remains alive for as long as weak references still exist, providing a stable place to resolve those references to nil.

This design addresses the main limitation of the earlier approach. Large objects no longer need to linger in memory after their useful lifetime has ended. Only a small auxiliary structure stays around, which is far less costly. It also simplifies reasoning about object destruction, since deallocation no longer depends on when weak references happen to be accessed.

At the same time, the new model keeps the fast path intact. Objects that never participate in weak referencing avoid the overhead entirely. The additional cost is paid only when the feature is actually used, which aligns better with real-world usage patterns where weak references are relatively rare compared to strong ones.

The introduction of side tables reflects a broader principle in the runtime design. Features that are not universally required should not impose a constant cost. By making the extended bookkeeping optional and moving it out of the object’s core layout, Swift achieves a more balanced trade-off between performance, memory usage, and flexibility.

Modern internals: HeapObject, inline counts, and the transition to side tables

To understand how this design works in practice, it helps to look at the layout of a Swift object at the runtime level. A reference type allocated on the heap is not just its stored properties. It begins with a small header that carries metadata and reference-counting state.

A simplified representation looks like this:

struct HeapObject {
    Metadata *metadata;
    InlineRefCounts refCounts;
};

The metadata pointer describes the type and enables dynamic dispatch. The second field, refCounts, is where the runtime keeps track of ownership. In the simplest case, this field contains both strong and unowned counts packed into a single machine word, along with a few flags that describe the object’s lifecycle.

This packed representation is one of the reasons the fast path is so efficient. Retaining an object does not require any lookup or allocation. It is a direct operation on the bits stored in the object itself.

A simplified version of the retain logic illustrates the idea:

inline void retain(HeapObject *object) {
    if (object == nullptr) return;
    object->refCounts.incrementStrong(1);
}

The actual implementation is more careful. It uses atomic operations and retry loops to handle concurrent access, but the essence remains the same. The runtime reads the current value, adjusts the relevant bits, and writes it back.

The interesting part lies in how these bits are organized. Instead of storing a single integer, the runtime splits the word into several regions. Some bits represent the strong reference count, others represent the unowned count, and a few bits act as flags. One of these flags marks whether the object is in the process of deinitialization. Another flag indicates that the inline representation is no longer in use.

That last flag is the key to switching between the fast and slow paths. As long as the object remains in its initial state, the runtime treats the field as a compact set of counters. Once certain conditions are met, the meaning of the same bits changes. Instead of holding counts, the field becomes a pointer to a side table.

The transition happens in two common scenarios. One is when the inline counters can no longer represent the required values. The other, more typical case, is when a weak reference is created. At that moment, the runtime allocates a side table and moves the bookkeeping data into it.

The structure of a side table is straightforward:

struct SideTable {
    HeapObject *object;
    RefCounts counts;
};

The object points to the side table, and the side table points back to the object. From that point on, all reference-counting operations are routed through this external structure.

The code that increments a reference count reflects this dual-mode behavior:

bool increment(HeapObject *object) {
    auto bits = object->refCounts.load();

    if (bits.usesInlineStorage()) {
        return object->refCounts.tryIncrementInline();
    }

    auto sideTable = bits.getSideTable();
    return sideTable->counts.tryIncrement();
}

In the inline case, the operation is a simple update of the packed bits. In the side table case, there is an extra level of indirection. The runtime must first follow the pointer to the side table and then update the counters stored there.

Weak references are the reason this indirection exists. A weak reference does not point directly to the object anymore. Instead, it holds a pointer to the side table. When the program reads a weak reference, the runtime goes through the side table, checks whether the object is still alive, and attempts to create a temporary strong reference.

A simplified version of that logic looks like this:

HeapObject *loadWeak(SideTable *table) {
    auto object = table->object;
    if (object == nullptr) {
        return nullptr;
    }

    if (!table->counts.tryRetainStrong()) {
        return nullptr;
    }

    return object;
}

This extra work explains much of the cost associated with weak references. Each access involves at least one additional pointer dereference and a conditional retain operation.

The same mechanism also affects strong and unowned references once a side table is present. After the transition, the inline counters are no longer used, and all operations go through the external structure. This means that even strong retains, which are normally extremely cheap, now involve an extra memory access.

What makes this design effective is that the slow path is opt-in. Objects that never participate in weak referencing remain entirely in the inline mode. They benefit from a compact layout and minimal overhead. Objects that do require weak references pay the cost, but only for as long as the feature is in use.

This dual representation allows the runtime to balance two competing goals. It keeps the common case fast and predictable, while still supporting more complex ownership patterns when needed.

How weak works today

With the underlying storage model in place, the behavior of weak references becomes easier to reason about in terms of access and lifetime rather than structure.

A weak reference does not point directly to the object. It holds a reference to the side table associated with that object. This indirection allows the runtime to decouple the lifetime of the object from the lifetime of weak reference bookkeeping.

Reading a weak reference follows a well-defined sequence. The runtime first resolves the side table and retrieves the current object pointer. It then checks whether the object is still alive. If the object has already been deallocated, the result is nil. If the object is still valid, the runtime attempts to temporarily retain it before returning it to the caller.

A simplified version of this flow looks like this:

HeapObject *loadWeak(SideTable *table) {
    if (table == nullptr) {
        return nullptr;
    }

    auto object = table->object;
    if (object == nullptr) {
        return nullptr;
    }

    if (!table->counts.tryRetainStrong()) {
        return nullptr;
    }

    return object;
}

The temporary retain is not an implementation detail that can be ignored. It ensures that once a weak reference has been resolved, the object remains valid for the duration of its use. Without it, the object could be deallocated between reading the pointer and accessing its memory, leading to hard-to-detect errors.

When the last strong reference is released, the object is deinitialized and its memory is reclaimed immediately. Weak references do not delay this process. Instead, they continue to point to the side table, which now no longer has a valid object. Any subsequent read simply returns nil.

The side table itself lives as long as weak references exist. Once the final weak reference is gone, the runtime can safely release this auxiliary structure as well. This creates a clean separation: strong references control the lifetime of the object, while weak references control the lifetime of the side table.

This model provides predictable behavior under concurrency and avoids keeping object memory alive longer than necessary. The cost is an extra level of indirection and additional work during access, which is why weak references are more expensive than their strong counterparts.

How unowned works today, and why it crashes

An unowned reference sits closer to a strong reference than a weak one in terms of representation. It typically points directly to the object rather than going through a side table. It does not keep the object alive, but it also does not produce an optional result. Accessing it assumes the object is still there.

That assumption is enforced at runtime.

When an unowned reference is read, the runtime does not simply return the stored pointer. It first attempts to turn that reference into a temporary strong reference. This step ensures that the object remains valid for the duration of its use. The operation looks roughly like this:

HeapObject *loadUnowned(HeapObject *object) {
    if (!tryRetainStrong(object)) {
        abortUnownedAccess();
    }
    return object;
}

The tryRetainStrong call checks the current state of the object. If the object is still alive, the strong count is incremented, and the caller receives a valid reference. After the use is complete, that temporary retain is released in the usual way.

If the object has already begun deinitialization or has been fully deallocated, the retain attempt fails. At that point, the runtime triggers a controlled crash. This is not undefined behavior. It is an explicit guard that prevents access to memory that no longer belongs to the object.

This design serves two purposes. It avoids silent corruption by detecting invalid access early, and it keeps the access path predictable when the lifetime assumptions hold. There is no need to go through a side table or perform additional lookups in the common case.

There is a less obvious consequence in terms of memory. An unowned reference contributes to a separate count that can delay the final release of the object’s memory. Once the last strong reference is gone, the object is deinitialized, but its storage may remain allocated while unowned references still exist. Only when the last unowned reference goes out of scope can the runtime reclaim that memory.

This creates a different trade-off compared to weak references. Access through an unowned reference avoids the indirection associated with side tables, but it relies on correct lifetime assumptions and may keep object memory around longer. Weak references take the opposite approach: they introduce additional indirection and runtime work, but allow the object’s memory to be freed as soon as strong ownership ends.

In practice, unowned references are best suited to cases where the lifetime relationship is strict and well understood. When that assumption is violated, the runtime does not attempt to recover. It stops execution at the point of invalid access, making the failure immediate and visible.

A note on unowned(unsafe)

There is a lower-level variant of unowned references that removes runtime checks entirely. An unowned(unsafe) reference is effectively a raw pointer to an object. It does not participate in reference counting, does not keep the object alive, and does not validate access.

Reading such a reference performs no safety checks. If the object has already been deallocated, the program may crash with a segmentation fault or, worse, continue running with corrupted memory. Unlike unowned, there is no controlled failure that points to a broken lifetime assumption.

This form exists for narrow, performance-sensitive scenarios where the cost of runtime checks is unacceptable and the lifetime relationship is guaranteed by other means. In typical application code, those conditions are rare. The absence of safeguards makes this option fragile under change and difficult to reason about in a team setting.

In practice, unowned(unsafe) belongs to the same category as other unsafe primitives. It can be useful in carefully constrained contexts, but it requires discipline and clear invariants. For most code, the safer variants provide a better balance between performance and reliability.

Performance and memory trade-offs: strong vs weak vs unowned

At a glance, the three reference types differ only in ownership semantics. Under the hood, they map to different execution paths in the runtime, each with its own cost profile. Understanding those costs helps explain why certain patterns behave the way they do.

Strong references follow the simplest path. In the common case, the runtime updates a counter stored directly in the object header. There is no additional allocation, no extra pointer chasing, and minimal synchronization beyond the atomic update itself. This is the path the system is optimized for, and it is the baseline for comparison.

Weak references introduce a different set of trade-offs. The moment a weak reference is created, the object transitions to using a side table. From that point on, accessing the reference involves resolving that table, checking the object’s state, and attempting a temporary retain. Each of these steps adds work. There is an extra pointer dereference, a higher chance of cache misses, and more coordination across threads. The initial creation of a weak reference also requires allocating the side table and moving reference-counting state into it. Once this transition happens, even strong and unowned operations for that object go through the same indirection.

Unowned references occupy a middle ground in terms of computation. They usually point directly to the object and avoid the indirection associated with weak references. Accessing them still involves a runtime check, because the reference must be promoted to a temporary strong reference before use. That promotion introduces additional operations compared to a plain strong access. It is cheaper than resolving a weak reference, but it is not free.

Memory behavior follows a different pattern. Weak references allow the object’s storage to be reclaimed as soon as the last strong reference is gone. The remaining bookkeeping lives in a small side table, which is released only after all weak references disappear. Unowned references can keep the object’s memory allocated after deinitialization, because the runtime must preserve enough state to detect invalid access. Strong references, by definition, keep the object fully alive.

These characteristics lead to a set of practical trade-offs. Strong references offer the most direct and predictable performance. Weak references provide safe, zeroing semantics and better control over memory retention, at the cost of additional work during access. Unowned references reduce some of that overhead but rely on strict lifetime guarantees and can extend the lifetime of the underlying memory in less obvious ways.

In most application code, the differences are small enough that correctness and clarity take priority. In performance-sensitive paths or systems with tight memory constraints, these details become more relevant.

Practical guidance for real iOS code

For day-to-day iOS development, the right choice usually starts with ownership rather than performance. A reference should first describe the lifetime relationship between two objects. Runtime costs matter, but they come after correctness.

A strong reference is the default because it matches the most common case. If one object owns another, that relationship should be expressed directly. This keeps the code simple and stays on the runtime’s fastest path. Many problems attributed to ARC begin when developers try to outsmart ownership too early and introduce non-owning references before the model is clear.

A weak reference fits relationships where the target may disappear independently and the caller can naturally handle absence. Delegates are the familiar example, though the same reasoning applies more broadly to parent-child relationships in UI code, callback holders, coordinator links, and caches of objects that should not be kept alive by observation alone. The optionality is part of the contract. When nil is a reasonable state, weak usually communicates the intent better than anything else.

An unowned reference belongs to a narrower category. It works well when one object must never outlive another, and that guarantee is structural rather than incidental. This can be true in carefully designed internals, certain tightly coupled model relationships, or low-level framework code where lifetimes are enforced by construction. In ordinary feature code, those guarantees tend to erode over time. Refactors change ownership, new asynchronous paths appear, and assumptions that once held quietly stop holding. A reference that once looked obviously safe can turn into a crash months later without any local change to its declaration.

That is why unowned deserves more restraint than it often gets. The appeal is understandable: the property is non-optional, the call sites look cleaner, and the access path is cheaper than weak. The hidden cost lies in the amount of certainty it demands from the surrounding architecture. Unless the lifetime relationship is rigid and easy to prove, weak is usually the better trade.

It is also worth avoiding performance folklore. Replacing weak with unowned for speed rarely makes sense without measurement. The main costs of weak references are real, though they are seldom the bottleneck in UI-driven code. If a path is hot enough for reference access overhead to matter, the problem usually deserves profiling and a broader look at the design rather than a local ownership tweak.

For closure capture lists, the same logic applies. [weak self] is appropriate when the closure may outlive the object and skipping the work after deallocation is acceptable. [unowned self] only makes sense when the closure cannot legally run after the object is gone. This tends to be true in a much smaller set of situations than codebases often assume. A crash here is the runtime exposing a lifetime model that was never as stable as it looked.

A useful rule of thumb is simple. Start with strong references for ownership. Use weak references when absence is valid and retain cycles are a real concern. Reach for unowned only when the lifetime guarantee is strict enough that you would be comfortable documenting it as an invariant of the type. That approach usually leads to code that is easier to maintain, safer under change, and well aligned with how Swift’s runtime is built.

Conclusion

Swift’s reference counting model did not arrive in its current form by accident. It evolved through a series of trade-offs, each addressing real constraints around performance, memory usage, and correctness. Early designs favored simplicity and locality, keeping all bookkeeping close to the object. Later revisions introduced more flexible structures that allowed the runtime to handle complex ownership patterns without imposing unnecessary cost on every object.

The result is a system with two distinct modes. The fast path handles the majority of cases with minimal overhead. The slow path activates when additional guarantees are required, such as safe weak references under concurrency. This separation keeps everyday code efficient while still supporting more demanding scenarios.

From the outside, strong, weak, and unowned look like small variations in ownership semantics. Under the hood, they represent different strategies with different costs. Understanding those differences helps explain why certain patterns scale well, while others introduce subtle issues over time.

In practice, most decisions still come down to modeling ownership correctly. When that model is clear, the runtime works with you rather than against you. The deeper mechanics matter when you need them, but they remain an implementation detail as long as the code reflects the intended lifetimes.