Chapters

Hide chapters

Swift Internals

First Edition · iOS 26 · Swift 6.2 · Xcode 26

1. Where the Memory Lies
Written by Aaqib Hussain

When writing code, programmers frequently take for granted how smoothly Swift handles memory for us. Unlike earlier languages such as C or Objective-C, which required you to specify object sizes and manually release memory, Swift manages this behind the scenes. Under the hood, Swift makes carefully calculated measures to optimize memory usage. The purpose of this chapter is to guide you through that journey.

This chapter will explore the world of memory management. You’ll learn how Swift uses structs to optimize runtime, and when (and when not) they are the right tool for the job. Similarly, how is a class laid out in memory? How does the magic occur? You’ll examine how Swift’s memory model handles allocation on the stack vs the heap, and how Automatic Reference Counting (ARC) works in practice.

You’ll also understand the concepts of Copy-on-Write (CoW) and Value Semantics, and see how they make Swift highly performant and efficient by default. Along the way, you’ll build a clearer understanding of ARC and learn how to reason about memory management to avoid common pitfalls.

If you’ve ever been curious about what occurs when you write something as simple as let name = "Steve Wozniak" — this chapter is for you.

Memory Layout Basics: Value vs Reference Types

In Swift, by default, Classes, Actors, and escaping closures are reference types; on the other hand, Structs, Enums, functions, and non-escaping closures are value types. Being conscious of the choice of value and reference types is important to writing performant code.

Fundamentally, Swift allocates memory in two ways: via the stack and the heap. Swift automatically determines whether to use the stack or the heap based on the type of data structure.

  • The stack is allocated at runtime in a predictable manner.
  • The compiler knows the size and layout of value types, so it can prepare the stack frame size ahead of time.
  • The heap is allocated dynamically at runtime.
  • For the heap, the compiler doesn’t know the exact lifetime or size of reference types in advance, so memory is managed at runtime using Automatic Reference Counting (ARC).

Value types are generally faster in terms of performance and code integrity since they are stored on the stack, which operates on a push-pop mechanism in memory. Imagine the stack like a pile of plates: you can only add a new plate on top or remove the top one. This Last-In, First-Out (LIFO) system is very fast because it’s controlled by a simple stack pointer. Each time a function runs, a new frame with its local variables is pushed onto the stack. When the function finishes, that frame is popped off. The cost is no more than assigning an integer.

In contrast, reference type objects are stored on the heap, and the pointers to those objects typically reside on the stack or in another heap-allocated structure. Heap allocations are dynamic and complex compared to stack allocations, requiring a more sophisticated memory manager. To allocate memory, Swift searches for an unused block of memory; once it’s no longer needed, that memory is returned to the pool. Moreover, because multiple threads may allocate memory simultaneously, the heap must maintain integrity through locking or synchronization mechanisms.

Struct Allocation

Here, let’s look at some code and see what Swift is doing behind the scenes:

struct Size {
  var width, height: Double
  func area() -> Double {
    return width * height
  }
}

let size1 = Size(width: 0, height: 0) // 1
let size2 = size1 // 2
size2.width = 7 // 3
// use size1 
// use size2
print("size1 width: \(size1.width)") // Prints "size1 width: 0.0" // 4
print("size2 width: \(size2.width)") // Prints "size2 width: 7.0" // 5

Here is a struct called Size that contains two properties: width and height, along with a method that computes the area.

  1. Create an instance, size1, with (0, 0) as initial values.
  2. Assign size1 to size2.
  3. Change the width of size2 to 7.
  4. The object of size1 remains unchanged. Thus, it prints “size1 width: 0.0”.
  5. Prints “size2 width: 7.0”.

Because Size is a struct, Swift performs a copy of the instance when executing line // 2. At compile time, Swift determines the stack frame and allocates memory on the stack at runtime. The stack pointer is then adjusted to reserve space for the new value.

size1: width: width: height: height: size2: Stack
Stack allocated space for width and height

When the code executes (lines 1 and 2), the stack is populated with values for both size1 and size2. A distinct copy of the size2 instance is made because structs in Swift use value semantics.

size1: size2: Stack width: 0.0 0.0 height: width: 0.0 0.0 height:
Stack initialized space for width and height

As soon as line 3 executes, the width of size2 is updated to 7. This change does not affect size1. This behavior, where changes to one instance do not affect the other, is known as value semantics.

size1: size2: Stack width: 0.0 0.0 height: width: 7.0 0.0 height:
Value changed to 7.0 for width

Swift automatically reclaims memory by moving the stack pointer back when the function or scope in which it was declared exits. This effectively pops the stack, deallocating any local variables or value types stored there.

Class Allocation

In contrast, consider a similar piece of code with a class:

class Size {
  var width, height: Double
  init(width: Double, height: Double) {
    self.width = width
    self.height = height
  }
  
  func area() -> Double {
    return width * height
  }
}

let size1 = Size(width: 0, height: 0) // 1
let size2 = size1 // 2
size2.width = 7 // 3
// use size1 
// use size2

Here, the memory layout differs from the struct example because Size is a reference type. During compilation, Swift infers that size1 is a reference type. Consequently, at runtime, the stack doesn’t store the property values directly; instead, it reserves space only for a memory pointer that points to the instance’s location on the heap. The properties themselves live on the heap, rather than being stored inline on the stack.

size1: size2: Stack Heap
Stack allocated memory for pointers

On the line //1, when the variable is initialized, Swift allocates a block of memory on the heap that is larger than the 2 words needed for the properties alone. At a minimum, an additional 2 words are used for runtime metadata.

Note: On a 64-bit macOS system, 1 word = 8 bytes.

size1: size2: Stack Heap width: 0.0 0.0 height:
Heap initialized.

The additional two words in a class instance are used to store runtime metadata:

  1. The first word typically contains the isa pointer, which points to metadata that includes:
  • The class type
  • Layout and size information
  • Virtual method tables (vtables) or method pointers
  • Conformance tables
  • Superclass metadata

This metadata resides elsewhere in memory. Swift establishes this at compile time and manages it at runtime.

  1. The second word refers to Automatic Reference Counting (ARC), which tracks the number of references to an instance.

Now, the code on line // 2 executes, it assigns the size1 instance to size2. Unlike with a struct, a new copy of the object is not created. Instead, the size2 reference now points to the same object in the heap as size1.

size1: size2: Stack Heap width: 0.0 0.0 height:
size2 pointing to the same instance on heap.

When the value 7 is assigned to the width property of size2 on line // 3, it also updates the width property of size1. This behavior, known as reference semantics, can lead to unintended state sharing if not handled carefully.

size1: size2: Stack Heap width: 5.0 0.0 height:
Value change reflects on both instances.

After both instances are no longer in use, ARC will:

  • Deallocate the heap memory (if the reference count drops to zero)
  • Return it to the heap allocator for reuse, and
  • Pop the stack frame if the variable was stored on the stack (such as the reference variables themselves)

Where Allocations Happen

Swift stores the area() method in both the struct and class examples in the code segment, also known as the text segment. This is the read-only part of the app’s memory where the compiled machine code instructions are stored.

The main difference is how the method interacts with instance data.

For structs, Swift passes the value directly, and accessing properties like width and height is efficient without pointer indirection. For classes, Swift implicitly passes a reference to the instance on the heap (self). This requires dereferencing the pointer to access properties or call methods, which is slower than stack access and adds the general overhead of managing the object’s lifetime via ARC.

Static and class methods are also stored in the code segment, but they do not incur ARC overhead because they neither capture nor depend on instance-level self.

Swift stores static let variables in the read-only data segment of the binary. These are initialized once at runtime in a thread-safe way and embedded into the binary alongside string literals and global constants.

On the other hand, static var is stored in a global data section that supports write operations. Each static var has a single shared memory location, and Swift ensures thread-safe initialization.

With lazy var or static lazy var, the value is only initialized upon first access. Because initialization is deferred, Swift checks at runtime whether the value has already been created; if not, it computes and stores it for future use.

Class within Struct Allocations

Think about an example where a struct contains a class as one of its properties:

struct Owner {
  var name: String
  var vehicle: Vehicle
}

class Vehicle {
  var number: String
  init(number: String) {
    self.number = number
  }
}

The Owner struct is a value type, so it’s typically stored on the stack when used in a local scope. However, the vehicle property is a reference to a class instance and always lives on the heap.

So what does this mean?

  • The vehicle inside Owner is just a pointer to a Vehicle object on the heap.
  • Copying an Owner value only copies the reference to the Vehicle, not the Vehicle itself.
  • As a result, changes made to vehicle through one copy of Owner will be reflected in all copies that point to the same Vehicle instance.

Struct within Class Allocations

Imagine a case where a class contains a struct as one of its properties:

struct Position {
  var x: Int
  var y: Int
}

class Player {
  var name: String
  var position: Position
  init(name: String, position: Position) {
    self.name = name
    self.position = position
  }
}

The Player class is a reference type, which means its instances are always allocated on the heap. The position property, being a struct, is stored inline within the class’s heap-allocated memory block - rather than on the stack.

So what does this mean?

  • The Position is not stored on the stack; it’s part of the Player’s heap allocation.
  • However, since Position is a value type, it still exhibits value semantics.
  • This means if you assign or mutate position in one instance of Player, it will not affect another instance, unlike a class reference.

Enum and Actor Allocations

Just like structs, enums are value types, and Swift typically allocates them on the stack. If the enum becomes too large or is captured by an escaping closure, Swift may allocate it on the heap, similarly to large structs. Despite that, enums retain their value semantics, meaning when you assign or pass them, Swift makes a copy.

Similarly, actors are reference types, just as classes are. They are always allocated on the heap, and variables referencing them are stored on the stack. Under the hood, actors behave like classes in terms of memory layout, with the additional layer of isolation for safe concurrency. They also include runtime metadata, ARC reference counters, and method dispatch information, just as classes do.

Other Special Cases of Allocations

Aside from the memory allocation behaviors discussed earlier, there are some cases worth looking into:

  1. Structs that are Too Large
  2. Structs Captured by Escaping Closures

Structs that are Too Large

If a struct contains a very large amount of data, Swift may choose to allocate it on the heap to avoid overflowing the stack.

struct WholeLottaData {
  var data = [Int](repeating: 0, count: 1_000_000)
}

In this case:

  • The data property is a heap-allocated array regardless (since Array is a reference type).
  • However, if the struct contains multiple large properties or is used in a context where large copies are made, Swift may choose to store the entire struct on the heap to improve performance and avoid excessive stack usage.

Structs Captured By Escaping Closures

When a struct is captured inside an escaping closure, it may be stored on the heap. This is because the closure may outlive the stack frame where the struct was originally defined.

struct SomeTask {
  var id: Int
}

class ViewController {
  var closure: (() -> Void)?
  
  func schedule() {
    let task = SomeTask(id: 42)
    
    closure = {
      print("Task ID is \(task.id)")
    }
  }
}

Here’s what’s happening:

  • Since the closure escapes the schedule() function, any captured variables must outlive the function’s stack frame.
  • Swift stores the captured task struct on the heap to ensure its lifetime extends safely.
  • Even if a struct is stored on the heap, it retains its value semantics.

Allocation Summary

Here is the summarized overview of what was discussed earlier:

Default Allocation Kind Type Reference type class Value type struct Value type enum Reference type actor Heap Stack
(usually) Stack
(usually) Heap Always heap-allocated; accessed via a reference stored on the stack. Stored inline; can move to the heap if becomes large or is captured by an escaping closure. Same behavior as ; stored inline unless large or captured. struct Like , but with built-in concurrency isolation at runtime. class Notes
Summary of Memory Allocations

Anatomy of Swift Types in Memory

Swift provides a utility MemoryLayout<T>, which offers valuable insights into how a type is laid out in memory. It gives you three key properties:

size: the number of bytes used to store the instance (excluding any padding added due to alignment), stride: the number of bytes from the start of one instance to the start of the next in contiguous memory (includes padding), alignment: the required byte alignment for the type in memory.

This section analyzes how different Swift types, structs, classes, enums, and actors are represented in memory, how memory alignment and padding affect layout, and what hidden costs may be involved.

Struct Memory Layout

Structs are typically stored inline on the stack, so their memory layout is usually tightly packed. However, Swift may add padding to properly align the data. Consider the following struct:

struct Point {
  let x: Double // 8 bytes
  let y: Double // 8 bytes
  let isFilled: Bool // 1 byte
}

You can inspect the memory layout using:

print(MemoryLayout<Point>.size) // 17
print(MemoryLayout<Point>.stride) // 24
print(MemoryLayout<Point>.alignment) // 8

Take a look at the diagram below; it illustrates how Swift lays out this struct in memory:

  1. Size is 17 bytes (8 + 8 + 1).
  2. Stride is 24 bytes. Swift pads the remaining unused space to meet the alignment requirements.
  3. Alignment is 8 bytes. Double in Swift takes up 8 bytes and is the largest in this struct.

Memory Alignment Size Stride 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 Int Int Padding Bool
Hypothetical Swift struct memory layout demonstrating alignment, padding, size, and stride.

Due to alignment rules, even though only 17 bytes are needed, Swift pads the Point to 24 bytes to ensure proper alignment in memory. This way, any subsequent value stored after this struct will start at byte 25, ensuring proper alignment and efficient memory access.

Class Memory Layout

In the case of classes, unlike structs, the actual values are not stored inline on the stack. Instead, a reference (or pointer) to the instance is stored on the stack, while the actual object is allocated on the heap.

Look at the following code:

class Vehicle {
  var speed: Int
  var isRunning: Bool
  init(speed: Int, isRunning: Bool) {
    self.speed = speed
    self.isRunning = isRunning
  }
}

If you inspect its layout using MemoryLayout:

print(MemoryLayout<Vehicle>.size) // Size of the reference value (usually 8 bytes)
print(MemoryLayout<Vehicle>.stride) 
print(MemoryLayout<Vehicle>.alignment)

This will return the size of the reference, which is usually 8 bytes on 64-bit systems, not the size of the actual object in memory.

To inspect the size of the actual class instance stored on the heap, you can use the code below:

print(class_getInstanceSize(Vehicle.self)) // 32 bytes

Heap 16 bytes isa pointer + class metadata 0x08 8 speed (Int) 0x09 1 isRunning (Bool) 0x10 7 Padding 0x11 Total size: 32 bytes
Hypothetical class memory layout demonstrating metadata and paddings.

Here’s how it works:

  1. 16 bytes are reserved for internal use by the runtime, typically including the isa pointer and other metadata such as reference counts and alignment buffers.
  2. The class has an Int and a Bool, consisting of 8 bytes and 1 byte, respectively.
  3. Swift pads the instance to align to the largest member (Int, 8 bytes), adding 7 bytes of padding after the Bool.

Enum Memory Layout

Enums in Swift are stored similarly to structs, but with one key difference: they need an extra byte (or more) to track which case is currently active. This is often referred to as the discriminator or tag.

Check the code example below:

enum Result {
  case success(Int)
  case failure(String)
}

To examine the memory layout, run the code below:

print(MemoryLayout<Result>.size) // 17
print(MemoryLayout<Result>.stride) // 24
print(MemoryLayout<Result>.alignment) // 8

Here’s a deconstruction of each line:

  1. Size is 8 + 8 + 1, a total of 17 bytes, with 8 for the associated value of Int, 8 for the String, and 1 for storing the tag.
  2. Stride is 24 bytes.
  3. Alignment is 8 bytes because the largest member requires 8-byte alignment.

Actor Memory Layout

Actors are reference types in Swift. Like classes, they are stored on the heap, with a reference to the instance held on the stack.

However, the Swift runtime may allocate additional memory to support concurrency guarantees, including internal queues, isolation state, or locking mechanisms to ensure thread safety.

actor Counter {
  var value = 0
}

To check the layout for an actor, do as follows:

print(class_getInstanceSize(Counter.self)) // 120

The 120 bytes reported include:

  1. 16 bytes for internal metadata (e.g., isa pointer and reference count).
  2. 8 bytes for the Int property.
  3. 96 bytes reserved for the actor’s runtime internals related to isolation and scheduling.

Thus, even a minimal actor has a larger memory footprint than a class due to its built-in concurrency support.

ARC: Automatic Reference Counting

ARC (Automatic Reference Counting) is Swift’s memory management system for reference types, such as classes and actors. Every time a new reference to an instance is created, ARC increments a reference count stored alongside the instance in the heap. This count helps the Swift runtime determine that the object is still in use and should not be deallocated.

As references are removed, the count is decremented. Once it drops to zero, Swift deallocates the instance and reclaims the memory.

When a new strong reference to an instance is created, ARC increases its reference count (an operation called a retain). When a reference is destroyed, such as when a variable goes out of scope, the count decreases (a release). Once the count reaches zero, Swift deallocates the instance. To support multi-threaded access, these retain and release operations are often performed using atomic operations, especially in the presence of concurrency. This ensures that even when multiple threads access or release references simultaneously, the reference count remains accurate. While this safety is necessary, it does introduce a small performance overhead.

Note: An atomic operation is one that cannot be interrupted by other threads, is not prone to race conditions, and completes as a single, indivisible step.

ARC Pitfalls: Retain Cycle

While ARC is powerful, it can cause a specific issue called a strong reference cycle (or retain cycle). This occurs when two or more objects hold strong references to each other, forming a loop that prevents them from being deallocated. This is a common type of memory leak.

To fix this, Swift provides two keywords: weak and unowned. You only need these tools to break retain cycles; they are not intended for everyday use.

weak tells the Swift runtime that this particular reference should not increment the instance’s reference count. The referenced instance can become nil at any point, so the reference must be declared optional.

unowned also does not increase the reference count, but assumes the referenced instance will always be available during the lifetime of the reference. It must be non-optional, and if the instance gets deallocated and the reference is accessed, it will crash at runtime.

Take a look at how weak works:

class Person {
  var name: String
  var pet: Pet?
  init(name: String) {
    self.name = name
  }
  
  deinit {
    print("\(name) is being deallocated")
  }
}

class Pet {
  var name: String
  weak var owner: Person? // weak reference to avoid retain cycle
  
  init(name: String) {
    self.name = name
  }
  
  deinit {
    print("\(name) is being deallocated")
  }
}

// ...

var john: Person? = Person(name: "John")
var dog: Pet? = Pet(name: "Buddy")
john?.pet = dog
dog?.owner = john
john = nil
dog = nil  

strong strong weak var john strong var dog pet: <Person instance> name: “Buddy” <Pet instance> pet: <Pet instance> name: “John” <Person instance>
Breaking a strong reference with weak.

If owner had been a strong reference, setting john = nil would not have deallocated the Person instance because dog would still hold a strong reference to it, resulting in a retain cycle. This scenario is illustrated below:

strong strong strong var john <Person instances> name: “John” pet: <Pet instance> strong var dog pet: <Pet instance> name: “John” <Person instance> pet: <Person instance> name: “Buddy” <Pet instance>
Retain cycle exists due to strong references.

Similarly, check the example of unowned:

class Customer {
  let name: String
  var card: CreditCard?
  init(name: String) {
    self.name = name
  }
  deinit {
    print("\(name) is being deallocated")
  }
}


class CreditCard {
  let number: UInt64
  unowned let customer: Customer // unowned because customer always owns the card
  init(number: UInt64, customer: Customer) {
    self.number = number
    self.customer = customer
  }
  deinit {
    print("Card #\(number) is being deallocated")
  }
}

// ...

var alice: Customer? = Customer(name: "Alice")
alice?.card = CreditCard(number: 1234_5678_9012_3456, customer: alice!)
alice = nil

Here, CreditCard holds an unowned reference to Customer. Since the card cannot exist without the customer, this avoids a retain cycle.

Retain Cycles in Closures

Closures can also capture self strongly, causing retain cycles, especially with escaping closures. This is common when a class instance holds a closure property or passes a closure to something that stores it beyond the current scope.

In older versions of Swift, self was captured implicitly, which could easily lead to accidental retain cycles. Modern Swift helps by requiring you to explicitly write self. inside a closure, making the capture clear. However, this doesn’t prevent retain cycles; it only makes you aware of the capture.

Check the following example:

class ViewModel {
  var name = "Steve"
  var onUpdate: (() -> Void)?
  func setup() {
    onUpdate = {
      print(self.name) // self is captured strongly here
    }
  }
  deinit {
    print("ViewModel deinitialized")
  }
}

// ...

var vm: ViewModel? = ViewModel()
vm?.setup()
vm = nil // deinit won't be called due to retain cycle

To fix this issue, self can be made [weak self] or [unowned self] to break the retain cycle.

func setup() {
  onUpdate = { [weak self] in
    print(self?.name ?? "nil")
  }
}

When to use which:

  1. Use weak when self can become nil, and you want to avoid crashes.
  2. Use unowned when you’re sure self will still be alive when the closure runs; otherwise, it will crash at runtime.

What does not create a retain cycle?

class ViewModel {
  var onUpdate: (() -> Void)?
  func setup() {
    let name = "Steve"
    onUpdate = {
      print(name)
    }
  }
  deinit {
    print("ViewModel deinitialized")
  }
}

// ...

var viewModel: ViewModel? = ViewModel()
viewModel?.setup()
viewModel = nil // deinit gets called due to no retain cycle

Since self is not captured in this closure, there’s no retain cycle, and the object is deallocated as expected.

Retain Cycles in Delegates

Retain cycles often occur in delegation patterns if the delegate is strongly referenced. Consider the following example:

protocol DownloaderDelegate: AnyObject {
  func downloadDidFinish()
}

class Downloader {
  var delegate: DownloaderDelegate?  //  This is a strong reference!
  
  deinit {
    print("Downloader deinitialized")
  }
  
  func startDownload() {
    // simulate some work
    delegate?.downloadDidFinish()
  }
}

class ViewController: DownloaderDelegate {
  var downloader: Downloader?
  
  init() {
    downloader = Downloader()
    downloader?.delegate = self  // self strongly owns downloader, and downloader strongly owns self
  }
  
  func downloadDidFinish() {
    print("Download finished")
  }
  
  deinit {
    print("ViewController deinitialized")
  }
}

// ...

var viewController: ViewController? = ViewController()
viewController = nil  // Neither deinit is called due to retain cycle

Here, a retain cycle exists because ViewController strongly owns the Downloader, and the Downloader strongly owns its delegate, which is ViewController.

To fix it, you can declare the delegate as weak:

weak var delegate: DownloaderDelegate?

Now, re-running:

var viewController: ViewController? = ViewController()
viewController = nil  // Both deinit methods are called — no retain cycle

The retain cycle is eliminated because Downloader no longer retains its delegate strongly. delegate is a weak reference, so it doesn’t increase the reference count of ViewController. Thus, when viewController is set to nil, both objects are properly deallocated.

Copy-on-Write

Copy-on-write is a performance optimization Swift applies to certain value types, such as Array, Set, Dictionary, Data, and String.

When you assign these value types to a new variable, Swift doesn’t immediately create a new copy of the underlying data; both variables point to the same instance in memory.

Only when one of the variables attempts to mutate the data does Swift create a copy to keep the original value.

Take a look at the following example:

var array1 = [1, 2, 3]
var array2 = array1 // No copy yet — same underlying buffer
array2.append(4)     // Now a copy happens

Even though these value types are structs, they store their underlying data (like buffers, character storage, or hash tables) in the heap. Swift leverages the Copy-on-Write mechanism to ensure optimization.

Before making a mutation, Swift checks if the buffer is uniquely owned and that no other references point to it. If it is, the mutation is applied directly to the existing buffer.

On the other hand, if the buffer is shared, Swift creates a real copy and then applies the mutation to the new buffer to preserve value semantics.

This uniqueness check is typically performed using a function such as isKnownUniquelyReferenced, which determines whether a reference to a class instance is unique or shared.

Consider the following class:

final class Buffer {
  var storage: [Int]
  init(_ storage: [Int]) {
    self.storage = storage
  }
}

// ...

var buffer1 = Buffer([1, 2, 3])
var buffer2 = buffer1

print(isKnownUniquelyReferenced(&buffer1)) // false — shared with buffer2

buffer2 = Buffer(buffer2.storage) // simulate CoW — now buffer1 is unique

print(isKnownUniquelyReferenced(&buffer1)) // true — no longer shared

This example demonstrates Copy-on-Write behavior: initially, buffer1 is shared with buffer2, but after buffer2 is reassigned to a new instance, buffer1 becomes uniquely referenced.

Optimizing Swift Memory Behavior

While writing code, you shouldn’t just think of it as getting the job done, but think of it like developing the foundation of a ship. If the foundation is strong, it’s going to sail through rough times with ease. But if the foundation is weak, the ship will start leaking and become unbalanced.

Your code is no different. If you don’t think it through before writing, especially when it comes to memory management, it may lead to leaks, performance issues, and long-term headaches.

When to Use Value vs Reference Types

Choosing between a value or reference type depends on several factors. If you’re aiming for predictability and isolation, go with a value type. When identity and shared mutable state matter, a reference type is usually the better option.

Value types provide safer, simpler memory behavior, especially with optimizations like Copy-on-Write, but copying large data structures frequently can be expensive. On the other hand, reference types allow shared memory access but bring along the overhead of ARC.

Consider this practical example to help you understand these trade-offs and write better and more efficient code.

Example: Caching Avatars

Look at the following code sample:

enum AvatarStyle { case cartoon, realistic, pixel }
enum Mood { case happy, serious, surprised }
enum Accessory { case none, glasses, hat }


var avatarCache = [String: UIImage]() // 1

func generateAvatar(style: AvatarStyle, mood: Mood, accessory: Accessory) -> UIImage {
  let cacheKey = "\(style)-\(mood)-\(accessory)" // 2
  
  if let cachedAvatar = avatarCache[cacheKey] {
    return cachedAvatar
  }
  
  // Placeholder avatar generation
  let avatar = UIImage() // Pretend this is generated with the given options
  
  avatarCache[cacheKey] = avatar
  return avatar
}

Consider that you’re developing an app that displays a list of users who have signed up on your app. Behind the list is a method that creates an avatar based on the Style, Mood, and Accessory you pass as arguments. To avoid recreating the same avatar, a dictionary is used to check whether a similar avatar exists based on the passed enums. The method creates a key based on serializing the passed enums.

Now here is the problem, this method is called every time a scroll event occurs in a TableViewController. If you recall, even though String is a value type, its contents are often stored on the heap, especially when the string gets longer or is dynamically constructed. So, every time this method runs during a scroll, Swift allocates and hashes a new key, which adds avoidable overhead.

Using a String key is also less type-safe. If you ever refactor your enums or misspell a string interpolation, it could silently break the caching logic. A struct enforces type correctness at compile time.

Define a new struct like this:

struct AvatarConfiguration: Hashable {
  var style: AvatarStyle
  var mood: Mood
  var accessory: Accessory
}

Update your cache and key as follows:

var avatarCache = [AvatarConfiguration: UIImage]()
let cacheKey = AvatarConfiguration(style: style, mood: mood, accessory: accessory)

Why does it work?

Because structs are first-class types in Swift. For this reason, structs can be used as dictionary keys. And since AvatarConfiguration is a struct, it’s a value type that avoids heap indirection and gives better performance for this use case.

Pretty cool, huh?

Note: Heap indirection, in this context, refers to following a pointer to access the actual data that lives on the heap. With a struct, the data lives inline on the stack, avoiding the extra lookup, which can improve performance.

Example: Safer Document Struct

Take a look at the following code:

struct Document {
  let path: URL
  let identifier: String
  let fileType: String
  init(path: URL, identifier: String, fileType: String) {
    self.path = path
    self.identifier = identifier
    self.fileType = fileType
  }
}

Furthermore, consider that the app you’re working on also supports users’ document uploads. The document has:

  1. A path of type URL
  2. An identifier of type String
  3. A fileType of type String

At first glance, this struct looks fine. But take a closer look. This struct includes types like String and URL, which often store their contents on the heap, leading to more allocation overhead, especially when used frequently.

There is also a type-safety concern. In the identifier variable, you can pass a “abc” or “123”. Similarly, in fileType, you can pass “doc”, “john”, or “xyz”. This may not only cause performance issues but also underlying bugs that would be harder to catch in more complex systems.

Something should be done about it.

First, replace the identifier: String with identifier: UUID. Swift’s Foundation framework provides a 128-bit randomly generated identifier, which is ideal here. It’s more type-safe, more compact, and, in many cases, more stack-friendly.

Then, the next step is to fix the file type issue.

enum DocType: String {
  case pdf
  case docx
  case pages
}

Using an enum here avoids raw string literals scattered in your codebase. It’s easier to refactor, harder to misuse, and you can still get the raw value if needed. So, your improved struct becomes:

struct Document {
  let path: URL
  let identifier: UUID
  let fileType: DocType
  init(path: URL, identifier: UUID, fileType: DocType) {
    self.path = path
    self.identifier = identifier
    self.fileType = fileType
  }
}

Although these may seem like small changes, they add up. Thinking about memory behavior, type safety, and allocation costs helps you write code that’s not only efficient but also easier to reason about, maintain, and refactor in the long run.

You should always aim to build ships sturdy enough to withstand any storm.

Minimizing ARC Overhead

As discussed earlier, reference types come with ARC overhead, and you even walked through some examples. As a programmer, your goal should be to write code that minimizes this overhead wherever possible.

Here are a few tips to keep your code in check:

Not Everything Has to Be a Class

Avoid using classes unless identity or shared mutable state is truly needed. If not, prefer structs or enums. Value types are generally safer and more efficient in terms of memory.

Break Strong References

Strong reference cycles are sneaky. Closures and delegates are the usual suspects. Use weak or unowned references where appropriate to prevent retain cycles.

Keep Classes Small

The larger and more tightly coupled your classes are, the harder ARC has to work. Smaller, focused classes not only reduce ARC overhead but also make your code easier to reason about and test.

Measuring and Debugging Memory

Understanding memory and how it works is one thing, but measuring it as it plays out is another. Without seeing how your app behaves in real-world scenarios, you can only infer behavior.

Thankfully, Apple provides powerful tools to measure those insights — monitoring allocations, catching leaks, and spotting inefficiencies early on.

Instruments (Allocations and Leaks)

Xcode provides a profiling tool to check for allocations and memory leaks. It’s an essential tool for diagnosing memory-related issues. Whether you want to trace memory allocations or detect leaks, it’s your go-to.

Memory Graph Debugger

Xcode’s built-in tool gives you a visual representation of retain cycles. It shows how many active instances of a class are in memory and how they’re being retained. You can see the entire graph of object relationships at a glance.

Tip: Make it a habit to check these insights during development, not later. It’s always easier to catch and fix things early than to untangle memory messes at the end.

Key Points

  • Swift manages memory for you under the hood, but understanding how it works helps you write more efficient, faster, safer, and more predictable code.

  • Structs and Enums are value types. Classes and Actors are reference types.

  • Value types are stored on the stack, and reference types are stored on the heap and managed using ARC.

  • Structs and Enums use value semantics, meaning they are copied rather than shared. Classes and Actors rely on reference semantics, meaning they refer to the same shared instance.

  • Swift optimizes value types using Copy-on-Write (CoW). Even though value types are copied, the copy only happens when a change is made. CoW in Array, Dictionary, Set, Data, and String helps avoid unnecessary memory duplication.

  • Structs are stored inline in memory, but if they become too large, they may be heap-allocated.

  • Classes include hidden metadata such as the isa pointer and reference counter, which increases the overall memory cost.

  • Actors are like classes but come with built-in concurrency and isolation, resulting in a larger memory footprint.

  • ARC keeps track of references to class and actor instances. Once the count drops to zero, the memory is deallocated.

  • ARC operations are often atomic to ensure thread safety, but they come with some performance overhead.

  • Retain cycles can sneak into objects through strong references, especially in closures and delegates, which are often the most common culprits of memory leaks.

  • Choose a value type when you’re aiming for predictability and isolation. Use a reference type when identity and shared state matter.

  • Tools like Instruments (Allocations & Leaks) and the Memory Graph Debugger in Xcode help you track memory usage and visually detect leaks.

  • Build sturdy ships: keep classes small, break strong references, and reach for value types first when they fit the scenario.

Where to Go From Here?

Now that you’ve peeked under the hood of how Swift’s memory management works, you’re better equipped to reason about performance, structure your types with clarity, and avoid common ARC pitfalls such as retain cycles and excessive heap allocations.

Up next, you’ll look at the different types of dispatch and how protocols further shape your code’s behavior at both compile time and runtime.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.