Close-up of Swift code on a MacBook screen

Writing Swift that runs is one thing. Writing Swift that screams is another. After years of building apps and fighting slow frame rates, I’ve come to see the compiler as a partner — one with a specific personality. Feed it code shaped a certain way, and it gives you tight, fast binaries. Ignore its preferences, and you get overhead that shows up in a profiler later. This guide is about meeting the compiler halfway. Every pattern here comes from real projects, real Instruments traces, and real fixes.

1. Let Value Semantics Do the Heavy Lifting

Swift nudges you toward structs and enums. The compiler likes these because it knows exactly how long they live and where they sit in memory — no shared state, no retain counts to track. When you hand a struct around, the compiler often skips making a copy altogether. That’s copy elision. It just reuses the same memory. No heap allocation, no reference counting.

Classes, on the other hand, come with baggage. Each assignment bumps a reference count, and the compiler has to place retain and release calls. In a tight loop, that’s measurable. I swapped a small model from class to struct once and shaved 20% off a hot path. Not bad for changing a keyword.

// Good: stack allocation, copy elision
struct Point {
    var x: Double
    var y: Double
}

// More overhead: heap allocation, reference counting
class PointClass {
    var x: Double
    var y: Double
    init(x: Double, y: Double) {
        self.x = x
        self.y = y
    }
}

Try a struct first. Switch to a class only when you truly need identity or shared mutable state. Your compiler will pay you back with better optimization passes.

2. Keep Protocol Witness Tables Light

Protocols are great for abstraction, but they have a price when you use them as existential types — that any Protocol syntax. An existential wraps your value in a container. If the value is small, it goes in the container. If it’s big, it gets heap-allocated. Plus, there’s a protocol witness table — a function dispatch table. Every call through an existential goes through that table. The compiler can devirtualize calls if it knows the concrete type at compile time, but across module boundaries, that knowledge evaporates.

Two ways to dodge the cost:

  • Generics instead of existentials. With func foo<T: MyProtocol>(value: T), the compiler stamps out a specialized copy for each concrete type. Witness table lookups become direct calls — and often just disappear via inlining.
  • The some keyword for opaque return types. You tell the compiler a fixed, specific type is coming back, but the caller doesn’t need to know which one. Dispatch is static.
// Existential: runtime dispatch through witness table
func process(shape: any Shape) {
    shape.draw() // dynamic
}

// Generic: compiler specializes, can inline
func process<T: Shape>(shape: T) {
    shape.draw() // static, possibly inlined
}

// Opaque return: static type known at compile time
func makeShape() -> some Shape {
    return Circle()
}

Unless you’re mixing types in a single collection, pick generics or some. In any drawing loop or data pipeline, the difference hits you in the frame rate.

Swift compiler optimization diagram on whiteboard

3. Give the Compiler Visibility

The Swift compiler can optimize aggressively, but only when it can see what’s going on. Whole-module optimization (-wmo) helps, but you can design your code to open things up even more.

Swift defaults to internal. Inside a module, the compiler assumes nothing outside will mess with an internal declaration, so it inlines, eliminates dead code, and propagates constants freely. Mark something public or open, and suddenly there are unknowns — a subclass elsewhere might override a method, or a stored property could be observed externally.

Keep open only for library designs that demand subclassing. Even public should be just your real API surface. For app internals, stay internal or private. I’ve watched the compiler delete entire call stacks because it proved the result was never used inside the module.

Then there’s final. On a class, it says “no subclasses.” That turns vtable dispatch into a direct call. Pair final with whole-module optimization, and short methods often get inlined.

// Without final: compiler assumes possible override
class Calculator {
    func compute() -> Int { ... }
}

// With final: direct dispatch, inlining candidate
final class FastCalculator {
    func compute() -> Int { ... }
}

4. Optimize Reference Counting

Even when you lean on value types, classes and closures creep in. The compiler adds ARC (Automatic Reference Counting) to manage them. Those retain/release calls are quick, but in a hot loop, they pile up. A scrolling list with thousands of cells can become a retain/release factory.

Two tactics:

  • Use unowned or weak with care. weak adds optional unwrapping and bookkeeping. unowned skips that but crashes if the object is gone. When a parent owns a child, unowned cuts the ARC churn.
  • Eliminate unnecessary closures that capture self. Each capture list entry adds a retain. If a closure is non-escaping (the default in many Swift versions), the compiler can often skip the retain. But when you’re not sure, check it.

I once traced a jerky scroll to an escaping closure in every cell setup. Swapping in a direct method call removed thousands of retains per second. The frame rate smoothed out instantly.

5. Help the Compiler Specialize Generics

Generics keep your code clean, but the compiler can’t always see which concrete types you’ll use. Call a generic function from another module, and the body might not be visible. So no specialization — just a slow generic path.

Push the compiler toward specialization like this:

  • Keep generic code in the same module where it’s called. Full visibility means full optimization.
  • Mark small, hot functions @inlinable. The body travels across module boundaries, and the compiler specializes for each caller’s type. Watch out — this exposes your implementation and can bloat binary size.
  • Write concrete overloads for common types. A generic sort<T: Comparable> works, but if 90% of calls use Int, a dedicated sortInts(_:) can fly.
// In a library module
public struct Math {
    @inlinable
    public static func square<T: Numeric>(_ value: T) -> T {
        return value * value
    }
}

// In the app module, the compiler sees the body and specializes for Int
let result = Math.square(42) // probably becomes 42 * 42

Developer analyzing Swift performance in Instruments

6. Align Data for SIMD and Contiguous Storage

Modern CPUs have vector units (SIMD) that crunch multiple values at once. Swift’s SIMD2, SIMD4, and SIMD8 types map right to those instructions. Do math on a SIMD4<Float>, and the compiler emits one vector instruction instead of four scalar ones.

But think about memory layout, too. An array of structs with mixed fields can scatter data, causing cache misses. A struct of arrays — each field in its own contiguous array — can be kinder to the cache. The compiler then vectorizes loops over those arrays more easily.

Instead of:

struct Particle {
    var x: Float
    var y: Float
    var vx: Float
    var vy: Float
}
var particles: [Particle] // array of structs

Try:

struct ParticleSystem {
    var x: [Float]
    var y: [Float]
    var vx: [Float]
    var vy: [Float]
}

Updating all vx values now hits a contiguous block of memory. The compiler can vectorize that loop without breaking a sweat. This pattern shows up a lot in high‑performance simulations.

7. Avoid Unchecked Bridging Overhead

Swift talks to Objective-C smoothly, but that bridge can get expensive in tight loops. Bridging a Swift String to NSString, or a Dictionary to NSDictionary, inserts calls that add up fast.

Minimize bridging like this:

  • Keep data in Swift-native types until you hit an Objective‑C boundary.
  • If you must talk to Core Foundation or legacy code, use CFArray or NSArray directly instead of converting a Swift array over and over.
  • Avoid NSNumber wrappers for simple numbers. Swift’s Int and Double aren’t objects; wrapping them just for a dictionary key allocates memory.

I once sped up a JSON parsing pipeline by 30% just by replacing [String: Any] dictionaries with a Codable struct that parsed straight into Swift types. All that bridging and type‑checking vanished.

8. Use Compiler Optimization Flags Intentionally

Your code is half the story; the compiler settings are the other half. Xcode’s default debug build uses -Onone — fast compilation, easy debugging. Release builds use -O for speed or -Osize for smaller binaries. Then there’s -Ounchecked, which strips away safety checks like array bounds and integer overflow detection.

My suggestions:

  • Use -O for release builds. It balances speed and safety. The compiler removes bounds checks only when it can prove they’re safe.
  • Reserve -Ounchecked for performance‑critical sections you’ve audited to death. One missed bounds check can crash your app.
  • Turn on Whole Module Optimization in build settings. It lets the compiler analyze your entire module as one unit, inlining and specializing across files in ways single‑file compilation can’t.

You can also attach optimization attributes to individual functions. @optimize(speed) and @optimize(size) override the module‑wide setting for just that function. I do this when one hot path needs all the speed, but the rest of the app is built for size.

@optimize(speed)
func processFrames(_ frames: [Frame]) {
    // Compiles with -O even if the module uses -Osize
}

FAQ

Does using structs always guarantee better performance than classes?

No, not always. Structs are copied when passed, though the compiler elides many copies. Large structs passed around frequently can still cause overhead from copying data. Profile your case. For most model data under a few hundred bytes, structs win — stack allocation, no reference counting.

When should I avoid @inlinable?

Skip @inlinable when the function body is long or likely to change in future library versions. Inlining a big function bloats binary size and compile time. Since the body becomes part of the caller’s binary, changing it later means recompiling all clients. Save @inlinable for small, stable, high‑impact functions.

How can I check if the compiler optimized a specific call?

Look at the Swift compiler’s SIL (Swift Intermediate Language). Add -emit-silgen or -emit-sil to see raw and optimized SIL. Search for apply (dynamic dispatch) vs. direct calls, or retain/release patterns. For a higher‑level view, run Instruments’ Time Profiler, find the hot spot, then inspect the assembly in Xcode’s debugger.

Is whole-module optimization worth the compile time increase?

For release builds, absolutely. The performance gains from cross‑function optimization usually outweigh the extra compilation time. For debug builds, leave it off to keep iteration fast. Configure this per build configuration in Xcode.