How to Write Performance-Sensitive Swift Code
Getting Swift to run fast isn’t just about picking the right algorithms. You have to understand how the language handles memory, how the compiler turns your code into something the CPU can chew through, and where the real bottlenecks hide. This piece walks through tangible techniques—things you can measure—from cutting down reference-counting churn to choosing the right dispatch path.

Understand Value Types vs. Reference Types
Swift gives you a clear fork in the road: structs and enums are value types, classes are reference types. Value types get copied when you assign or pass them around. That can wipe out shared mutable state and cut down on retain/release noise. But copying a big struct over and over has its own price tag. If you’re wrapping a Copy-on-Write type, lean on isKnownUniquelyReferenced to hold off on the copy until a mutation actually happens. When identity or lifecycle control matters, classes and ARC fit the bill—just remember that every retain and release operation chews a tiny bit of CPU time.
Minimize ARC Overhead with Structs and Enums
Each retain and release is an atomic operation that can stall the CPU pipeline. In hot loops, sidestep ARC entirely by leaning on value types. Model your data as structs where it makes sense, and use enums for state machines. When you’re mixing both worlds, fire up Instruments’ Allocations template and look for spots where retain/release pressure spikes—that’s your cue to refactor.
When to Use Classes Despite ARC Costs
Classes earn their keep when you need shared mutable state across different scopes, or when you’re talking to Objective-C frameworks that expect NSObject subclasses. The trick is locality: let class instances live a long life and avoid spawning temporary objects inside loops. If a loop absolutely has to create class instances, consider an object pool or rework the flow to use a value-type intermediate first.

Optimize Protocol and Generic Dispatch
Protocols in Swift can dispatch statically or dynamically, depending on how you use them. Generic code constrained by a protocol often gets static dispatch through specialization. Existential types—using a protocol as a type—go through the protocol witness table, which means dynamic dispatch. In a tight loop, the difference between the two can be the difference between fast enough and not.
Static Dispatch with Generics
Write func process and the compiler will cook up a specialized version for each concrete type you feed it. That opens the door for inlining and skips vtable lookups entirely. It’s a solid win, but it does bloat your binary. Keep an eye on the Code Size report in Xcode if your codebase has a ton of specializations.
Existential Dispatch and Box Overhead
Using a protocol as a type—say, let item: MyProtocol = MyStruct()—wraps the value in an existential container. Small values get packed inline. Big ones? They get heap-allocated. That means allocation and an extra level of indirection. For performance-sensitive spots, reach for generics or mark existentials with the any keyword (Swift 5.6+). Then audit: can you swap that existential for a concrete type?
Manage Memory Layout and Alignment
How you lay out a struct’s fields affects how many cache lines you’re burning through. Group fields by size to trim padding, or shove the hot fields together so they sit in the same cache line. Swift won’t reorder stored properties for you—you’re in charge. Use MemoryLayout to check size, stride, and alignment, then shuffle things around as needed.
Avoid Abusing Optional Properties
An optional enum tacks on a byte of metadata and can puff up a struct’s size thanks to alignment requirements. If a property truly is optional, fine. But sprinkling optionals around to represent “default” values often wastes memory. Try a sentinel value or a dedicated enum state instead. In arrays, [Int?] stores each element as an optional; a compact [Int] paired with a separate bitmap is way more cache-friendly.

Write Loop-Friendly Code
Loops magnify tiny inefficiencies until they’re not tiny anymore. Pull out invariant computations, hoist method calls that return the same result every time, and think about the cost of bounds checks. Swift arrays always bounds-check, but the optimizer can peel away redundant checks if the indexing pattern is predictable. When order doesn’t matter, use for element in array instead of manual indexing—it sidesteps repeated bounds checks.
Reduce Dynamic Dispatch Inside Loops
If your loop body calls a method on a protocol existential or a class instance, you’re paying the dispatch cost on every single iteration. Move that dynamic dispatch outside the loop. Assign the method to a local variable, or convert the existential to a concrete type before you enter the loop. For class methods, slapping on final or putting them in a final class lets the compiler make direct calls.
Use Contiguous Storage and Unsafe Pointers Judiciously
Array gives you contiguous storage, but bridging to NSArray can break that promise. In performance-critical sections, reach for withUnsafeBufferPointer to touch raw memory and dodge retain/release overhead when you’re iterating over object references. UnsafeMutablePointer hands you the keys to manual memory management—but you’re on the hook for initialization and deallocation. This is a sharp tool; run Address Sanitizer to make sure you don’t cut yourself.
Use the Compiler Optimization Flags
Xcode’s default debug build uses -Onone. That keeps debugging friendly but kills optimizations. When you’re testing performance, flip to -O (speed) or -Osize (smaller binary). -O turns on whole-module optimization, so the compiler can inline across file boundaries. With Swift Package Manager, add -cross-module-optimization to push that across module boundaries too.
Whole-Module Optimization
With WMO, the compiler sees all your source files in a module as one big chunk. It inlines aggressively and specializes generics based on how they’re actually used. Compilation takes longer, but the hot paths get faster. Turn it on in build settings and measure the difference with a benchmark suite. For libraries, @inlinable lets you expose functions for cross-module inlining. Just be careful: if you change the body of an inlinable function, clients have to recompile.
Profile Before Assuming
Your gut feeling about performance is probably wrong. Don’t trust it. Use Instruments’ Time Profiler to hunt down hot spots, Allocations to track memory churn, and Swift Counters to peek at ARC operations and existential allocations. The os_signpost API lets you mark regions for precise tracing. Always profile a release build on a real device, under workloads that match what your users actually hit.
FAQ
Why does my Swift array seem slower than a C array?
Swift arrays are value types with copy-on-write semantics. You don’t get an immediate copy on assignment or pass, but the potential for copying adds overhead checks. On top of that, Swift arrays bridge to NSArray, and element access can trigger dynamic dispatch if the compiler can’t prove the storage is contiguous. Try ContiguousArray when you never need Objective-C bridging, and verify your array isn’t being copied by accident—use isKnownUniquelyReferenced on its buffer.
How do I know if a protocol is causing dynamic dispatch?
Compile with -Xfrontend -debug-time-expression-type-checking to see type-checking times, but for dispatch, look at the SIL (Swift Intermediate Language) output with swiftc -emit-sil. Search for witness_method calls when a protocol is used as an existential. Or run Instruments with the Swift Counters template and filter for existential box allocations. A flurry of allocations in a hot path means you’re paying for dynamic dispatch.
Does using lazy improve performance for sequences?
lazy defers operations like map and filter until you actually iterate the sequence, and it skips creating intermediate arrays. That can cut peak memory and speed things up when you chain several operations but only iterate once. The catch: lazy sequences add an abstraction layer that can get in the way of some compiler optimizations. Measure both approaches. For small collections, the eager version often wins because the overhead just isn’t worth it.