Releasing an iOS app is exciting—until you watch animations stutter, list scrolling lag, and users start to leave negative reviews about performance. This was my reality not long ago. Like many, I leaned on class-based model objects, foundation patterns I trusted, and code inherited from frameworks. But our app was growing, functionality ballooned, and it had become slow—frustratingly so for a codebase only two years old.
Curious if there was a better way, I started dissecting Apple’s own design philosophies. The result? An ambitious, app-wide experiment in replacing reference-heavy patterns with Value Semantics: namely, Swift structs. The outcome astonished me: Not only did I double the performance by key metrics, but I also simplified code and future-proofed maintainability. Here’s the journey—let’s explore each phase, from motivation to insight and benchmarks to practical steps.
Performance regressions snuck up on me as modules proliferated and data models became more complex. Sign-in screens bubbled with cumbersome spinner delays. Feed reconstruction caused visible lags when users refreshed. Simple list scrolling—a joy in the earliest builds—now stuttered if lists were even moderately large.
We tracked performance daily on production usage with Firebase Performance Monitoring and custom logging. Typical pain points included:
UITableView
cells stuttered with high TTI (Time To Interact) exceeding 120 ms when rendering complex items.Profiling in Instruments showed suspiciously high CPU usage during simple operations. Formally, our technical debt diagnosis pointed to excessive copying, retain/release churn, and ARC overheads—especially around complex model classes reused across view controllers.
Swift, unlike Objective-C, features first-class value types: namely, structs. By default, many Cocoa and Apple frameworks (like String
, Array
, Dictionary
) are built atop copy-on-write structs. Our models, on the other hand, were classes—classically modeling entities with inheritance chains:
class UserProfile: NSObject, Codable {
var id: String
var name: String
var points: Int
}
Imagine a hierarchical feed structure with FeedItem
, UserProfile
, and Comment
—all classes. Navigating, filtering, and sorting required copying arrays of objects, but with classes, only references were copied. This led to subtle side effects: shared mutable state, thread-safety issues, strong reference cycles, as well as expensive retain and release operations under the hood (ARC activity was a top Instruments culprit).
In contrast, structs in Swift are value types. Each assignment creates a copy unless the optimizer determines the copy unnecessary. Most importantly, there’s no reference counting. With value semantics:
Emboldened by Apple’s message about structs, I began refactoring model objects with the goal of minimizing classes and maximizing structs. The guiding rules I adopted:
So, a typical feed item morphed from:
class FeedItem: Codable {
var id: String
var author: UserProfile
var content: String
}
into:
struct FeedItem: Codable {
let id: String
let author: UserProfile
let content: String
}
Every table or collection-backed model, settings state, auxiliary payload, and parsing intermediary was migrated to a struct where possible. We audited across the codebase, targeting areas where model mutability wasn’t a requirement and where performance bottlenecks had appeared.
var
with let
where feasible.Equatable
, Hashable
for sorting/search; Codable
for networking.How do you prove a speed gain wasn’t placebo? I rigorously measured impact across several areas.
New relic traces revealed app cold launch times dropped by 35–47%, getting from splash to interactive UI within 800 ms (from previous levels of 1.4–1.7 seconds). The profiler highlighted the elimination of ARC retain/release cycles on repeated model graph construction.
Lists with over 200 items previously introduced micro-stutters, primarily during cell-to-model binding. Profiling found:
Actions that updated data—like favoriting, commenting, or refreshing content—were vastly more responsive. Value type updates resolved synchronously, unburdened by main-thread ARC activity or contention for shared state locks.
User Satisfaction improved measurably: our Spring 2023 update led to a sharp reduction in performance complaints, and our app’s App Store rating rose by 0.4 points over six weeks post-launch.
The reasons Swift structs accelerate iOS apps are both architectural and behavioral:
Classes use Automatic Reference Counting (ARC), which adds a reference count field, tracks ownership, and injects retain/release calls into code. For high-frequency model lifecycles, this imposes significant work. Structs don’t participate in ARC—they’re just data.
Instruments View — Pre-Refactor: Yellow in the “Allocations” graph depicted waves of retain
/release
during model-creation.
Post-Refactor: ARC events flatlined. Code ran cleaner, spending more time on actual business logic.
Swift structs, especially small ones (< 3 word size), often reside on the stack (unless optimized away entirely). Memory locality means less L1/L2 cache thrash and fewer heap allocations—your app benefits every frame.
For collections (Array
, Dictionary
), Swift employs CoW. Replacing custom class-based tree/graph models with struct compounding allowed the optimizer to minimize wasteful copies. Our biggest feed-generation bottleneck—a list merge for pagination—became negligible after this change.
Because value types don’t share state by default, thread synchronizations shrink dramatically. Structs contributed to safer background parsing and diffing, banishing hard-to-reproduce crashes due to unexpected data races.
Take our UserProfile
model:
Before (class-based, hidden costs):
class UserProfile: Codable, CustomStringConvertible {
var id: String
var screenName: String
var status: Status
}
enum Status: String, Codable {
case active, deleted
}
Antipatterns included routine modification, unnecessary mutability, and ARC overhead. Tracking users or copying for async refreshes led to thread confusion.
After (struct-based, value semantics):
struct UserProfile: Codable, Equatable, Hashable, CustomStringConvertible {
let id: String
let screenName: String
let status: Status
}
let
(immutable after decoding or creation).Practical gain: We saw a 2X speed-up in rendering user info lists compared to the class-based implementation—traced via repeated synthetic list loads in testing.
Swift doesn’t outlaw classes—a wise architect chooses tools for the job. Here’s a cheat-sheet I distilled from experience:
Use Struct | Use Class |
---|---|
Data-centric models | Reference-based identity |
Pure value, e.g. DTOs | Side effects/callback storage |
Immutable or copy-on-write | Mutability required / shared |
Equatable/Hashable for free | Protocol as reference anchor |
Thread-safe by default | Need dynamic polymorphism |
Example: Models for the network/API boundary (User
, Message
, Feed
) fit structs perfectly. Controllers, services, systems with shared ownership (e.g. NotificationCenter
observer, system data caches) remain classes.
Tip: Whenever in doubt, start by designing with structs. Only refactor to classes if forced by context.
Transitioning a large codebase from class to struct-based models is rarely trivial. A few gotchas and their resolutions from our refactor:
Developers initially struggled with losing var
properties on the model. Education on creating updated copies (item = item.withUpdatedProperty(newValue)
) was necessary. I introduced:
extension UserProfile {
func updating(screenName: String) -> UserProfile {
UserProfile(id: id, screenName: screenName, status: status)
}
}
For some models, code relied on identity comparison (===
). Replacing with id checks or protocol-based equality resolved ambiguity:
users.map { $0.id == targetId }
Legacy code mixed classes and structs, leading to unpredictable behavior. We set strict linting rules, and documented struct/class boundaries—especially around networking, persistence, and UI logic.
@propertyWrappers (@State
, @ObservedObject
) in SwiftUI play better with value semantics. UIKit patterns (delegates, KVO) required explicit adaptation, mostly replacing delegate callbacks with functional-style closures.
Performance was our initial motivator, but value types brought deeper, lasting gains as our codebase matured.
Less shared mutable state cut support ticket volume from elusive data bugs. Our PR review time dropped—structs empowered test-driven patterns, created rare regression bugs, and reduced codebase fragility.
In state-centric architectures (think Redux or The Composable Architecture), working with value types simplified entire reducer setups, data flows, and made undo/redo logic nearly trivial.
Immutability and value semantics meant test set-up was as easy as instantiating fresh, stateless models. No more wrestling with mocks to control hidden reference graphs.
As we explored SwiftUI, we found the ecosystem intrinsically structured around value types—our refactored models and state slots could be dropped into the new architecture with comfort.
If you’re reading this because your app feels slow or chaotic, adopting structs isn’t just a craft flex—it’s an engineering edge. My practical roadmap:
Equatable
, Hashable
, and extensions to keep value types ergonomic.Structs are not a cure-all, but for data-rich iOS apps, they are transformative. We’ve found not only doubled speed, but the remove of a wide class of concurrency, testing, and refactoring headaches. Our codebase is leaner, safer, and ready for the ever-evolving Swift and SwiftUI world—all while users experience the fluid, buttery UI that keeps them coming back.
If you’re hitting scaling pains and sluggish performance, consider the bold but pragmatic value-type approach. Double your speed, halve your stress—and let your iOS app shine.