Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 045 / 100

SwiftUI performance: Swift & iOS lessons from production

Keep apps smooth.

Expona Admin9 min read

SwiftUI performance: Swift & iOS lessons from production

Performance degradation in SwiftUI apps often sneaks up gradually—a few extra view redraws here, a missed identity optimization there, and suddenly your smooth 60 fps list becomes a stuttering mess. We've learned the hard way across trading platforms, e-commerce apps, and ride-hailing systems that performance isn't something you bolt on at the end; it's a discipline you practice from day one. In this article, we'll walk through the concrete techniques that keep apps responsive in production, backed by real scenarios you'll recognize.

Small views — Smaller bodies update less work

One of the most powerful levers in SwiftUI optimization is the principle that smaller views update less work. Every time SwiftUI re-evaluates a view's body, it's running that entire closure. If your body contains complex layout logic, network calls masked as view builders, or deeply nested state reads, you're doing unnecessary work on every state change—even if that change doesn't affect the visual output.

The solution is straightforward: extract views aggressively. In a product listing screen for an e-commerce platform, we had a single view rendering 50+ product cells with filtering, sorting, price calculations, and rating stars all inline. Scrolling was choppy. The fix was extracting each cell into its own ProductCard view with only the data it needs.

swift
// ❌ Before: monolithic view, entire body re-evaluates on any state change
struct ProductList: View {
    @State private var products: [Product] = []
    @State private var sortBy: String = "relevance"
    @State private var filterPrice: Double = 1000
    
    var body: some View {
        VStack {
            HStack {
                Picker("Sort", selection: $sortBy) {
                    Text("Relevance").tag("relevance")
                    Text("Price: Low to High").tag("price_asc")
                }
                Slider(value: $filterPrice, in: 0...5000)
            }
            List(products.filter { $0.price <= filterPrice }
                    .sorted { ... }, id: \.id) { product in
                VStack(alignment: .leading) {
                    Text(product.name).font(.headline)
                    Text("$\(product.price, specifier: "%.2f")")
                    HStack {
                        ForEach(0..

Here, every time filterPrice changes, the entire List rebuilds—filtering, sorting, and rendering all product cards. On a list of hundreds of items, this kills performance.

swift
// ✅ After: extracted ProductCard with minimal dependencies
struct ProductCard: View {
    let product: Product
    
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(product.name).font(.headline)
            Text("$\(product.price, specifier: "%.2f")")
            StarsView(rating: product.rating)
            Text(product.description).lineLimit(2).font(.caption)
        }
        .padding()
    }
}

struct StarsView: View {
    let rating: Double
    
    var body: some View {
        HStack(spacing: 2) {
            ForEach(0..

Now, ProductCard only updates when its product prop changes. The filtering and sorting logic is computed once and cached in a computed property. Scrolling remains smooth.

Stable identity — Use stable IDs in ForEach

SwiftUI's diffing algorithm needs to track which view corresponds to which piece of data across updates. If you don't provide a stable ID, SwiftUI defaults to positional identity—and when data is inserted, deleted, or reordered, the IDs get confused. Views get recreated unnecessarily, animations break, and sometimes state gets attached to the wrong item.

We ran into this in a ride-hailing app's trip history list. When completed trips were fetched and prepended to the list, the IDs weren't stable, causing the wrong trip details to appear briefly and animations to glitch.

swift
// ❌ Before: positional identity, IDs shift when data changes
struct TripHistoryList: View {
    @State private var trips: [Trip] = []
    
    var body: some View {
        List(trips, id: \.self) { trip in
            TripRow(trip: trip)
        }
    }
}

// When a new trip is added at index 0, all IDs shift.
// The view at position 0 gets recreated. State breaks.
swift
// ✅ After: stable ID from trip.id
struct TripHistoryList: View {
    @State private var trips: [Trip] = []
    
    var body: some View {
        List(trips, id: \.id) { trip in
            TripRow(trip: trip)
        }
    }
}

// trip.id is stable. Inserting a new trip at index 0 doesn't change
// the identity of existing trips. SwiftUI updates only what changed.

The rule is simple: always use id: \.someUniqueField or adopt Identifiable on your model. Never rely on positional identity in production apps.

swift
// Best practice: make your model Identifiable
struct Trip: Identifiable {
    let id: UUID
    let origin: String
    let destination: String
    let timestamp: Date
}

// Now you can omit id: \.id; it's inferred
List(trips) { trip in
    TripRow(trip: trip)
}

Lazy containers — LazyVStack and List for long content

VStack and HStack render all children upfront. If you have 1,000 rows, all 1,000 views are built immediately. LazyVStack, LazyHStack, and List defer rendering until items are about to scroll into view. This is essential for long lists, searchable results, or infinite scroll.

In a CRM we optimized, a sales rep's contact list could contain thousands of records. Scrolling performance was abysmal because every contact card was being rendered, even those off-screen.

swift
// ❌ Before: VStack renders everything immediately
struct ContactList: View {
    @State private var contacts: [Contact] = []
    
    var body: some View {
        ScrollView {
            VStack {
                ForEach(contacts, id: \.id) { contact in
                    ContactCard(contact: contact)
                }
            }
        }
    }
}

// With 5,000 contacts, all 5,000 ContactCard views are built
// immediately, even though only ~10 are visible on screen.
swift
// ✅ After: LazyVStack renders only visible items
struct ContactList: View {
    @State private var contacts: [Contact] = []
    
    var body: some View {
        ScrollView {
            LazyVStack(spacing: 12) {
                ForEach(contacts, id: \.id) { contact in
                    ContactCard(contact: contact)
                }
            }
        }
    }
}

// Or, preferably, use List with built-in lazy rendering
struct ContactList: View {
    @State private var contacts: [Contact] = []
    
    var body: some View {
        List(contacts, id: \.id) { contact in
            ContactCard(contact: contact)
        }
    }
}

List is often the best choice for scrollable data because it combines lazy rendering with platform-standard behavior (pull-to-refresh, swipe actions, keyboard handling). LazyVStack is useful when you need custom layout but still want lazy rendering.

Instruments — SwiftUI and Time Profiler instruments find hot spots

Intuition about performance is often wrong. We thought a particular view was slow, only to find via Instruments that the bottleneck was elsewhere entirely. The solution is to measure.

Xcode's Instruments give you a precise view of where time is spent. The SwiftUI instrument tracks view body evaluations and lifecycle events. The Time Profiler shows CPU time by function. Together, they reveal the true source of jank.

To profile a SwiftUI app:

  1. In Xcode, go to Product > Profile (or press Cmd+I).
  2. Choose "SwiftUI" from the template list.
  3. Interact with your app in ways that feel slow.
  4. Pause recording and inspect the results.

The SwiftUI instrument shows you every view's body evaluation. If a view's body is being called 100 times per second when it should be 1, you've found a hot spot. The Time Profiler then shows you what work is happening inside that body.

swift
// Example: a view that updates too frequently
struct DetailView: View {
    @State private var selectedTab: Int = 0
    @ObservedObject private var viewModel: DetailViewModel
    
    var body: some View {
        TabView(selection: $selectedTab) {
            // Tab 1: Shows a map with live location updates
            MapView(location: viewModel.liveLocation)
                .tag(0)
            
            // Tab 2: Shows trip details
            TripDetailsView(trip: viewModel.trip)
                .tag(1)
        }
    }
}

In this example, if viewModel.liveLocation updates every 100ms (common for GPS data), the entire body re-evaluates every 100ms, even when viewing Tab 1. This causes Tab 2's content to unnecessarily rebuild. The fix: move the map update logic to the MapView itself and isolate state.

swift
// ✅ After: decouple tab content from shared state
struct DetailView: View {
    @State private var selectedTab: Int = 0
    @ObservedObject private var viewModel: DetailViewModel
    
    var body: some View {
        TabView(selection: $selectedTab) {
            LiveMapTab(viewModel: viewModel)
                .tag(0)
            TripDetailsTab(trip: viewModel.trip)
                .tag(1)
        }
    }
}

struct LiveMapTab: View {
    @ObservedObject var viewModel: DetailViewModel
    
    var body: some View {
        MapView(location: viewModel.liveLocation)
    }
}

struct TripDetailsTab: View {
    let trip: Trip
    
    var body: some View {
        TripDetailsView(trip: trip)
        // This view is now isolated. It won't update
        // when viewModel.liveLocation changes.
    }
}

Common mistakes

  • Performing expensive work in view bodies: Network calls, database queries, or complex calculations should never happen inside a body. Move them to computed properties, view model methods, or task-based side effects.
  • Using .self as ID: If your model is a class or contains reference types, using id: \.self can cause identity instability. Always use a stable value property.
  • Ignoring recomposition: Just because SwiftUI is declarative doesn't mean you get free performance. Track how often your view body is called using print statements or the SwiftUI instrument.
  • Mixing eager and lazy containers: A VStack inside a LazyVStack defeats the purpose. Keep lazy containers at the outermost level of scrollable content.
  • Over-optimizing prematurely: Profile first. Extract views and use lazy containers where data is large. Don't refactor tiny views unless you have evidence of a problem.

Key takeaways

  • Extract views aggressively so that state changes in one part of the tree don't trigger unnecessary rebuilds elsewhere.
  • Always use stable IDs in ForEach and List; make your models Identifiable when possible.
  • Use List or LazyVStack for scrollable content with more than a few dozen items.
  • Profile with Xcode's SwiftUI and Time Profiler instruments to find true bottlenecks; intuition often misleads.
  • Isolate expensive state (like GPS updates) in separate views so it doesn't trigger global re-renders.
  • Avoid network calls, database queries, and heavy computations inside view bodies; use async/await and view models instead.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles