Skip to content

Swift / SwiftUI — Pitfalls

OWNER: martijn, valentin ALSO_USED_BY: alexander (design integration) LAST_VERIFIED: 2026-03-26 GE_STACK_VERSION: Swift 6.1 / SwiftUI (iOS 26 SDK)


Overview

Known failure modes in Swift/SwiftUI development. Agents MUST check this page before submitting iOS code for review. New pitfalls are added here when discovered during development — items are NEVER removed.

Memory Leaks and Retain Cycles

Strong Self in Closures

ANTI_PATTERN: Capturing self strongly in escaping closures, Combine sinks, or Task blocks. FIX: Use [weak self] in every escaping closure.

// WRONG — retain cycle
class OrderViewModel {
    var onComplete: (() -> Void)?

    func setup() {
        onComplete = {
            self.refresh()  // Strong capture — cycle if onComplete is stored on self
        }
    }
}

// CORRECT
func setup() {
    onComplete = { [weak self] in
        self?.refresh()
    }
}

Delegate Retain Cycles

ANTI_PATTERN: Declaring delegate properties as strong references. FIX: Always mark delegate properties as weak.

// WRONG
var delegate: OrderDelegate?

// CORRECT
weak var delegate: OrderDelegate?

Timer Retain Cycles

ANTI_PATTERN: Creating a Timer that captures self without invalidating in deinit. FIX: Use [weak self] in the timer closure AND invalidate in deinit.

// CORRECT
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
    self?.poll()
}

deinit {
    timer?.invalidate()
}

Task Cancellation

ANTI_PATTERN: Starting a Task in a ViewModel without cancellation handling. FIX: Store the Task and cancel it when the ViewModel is no longer needed.

@Observable
final class OrderViewModel {
    private var loadTask: Task<Void, Never>?

    func startLoading() {
        loadTask = Task {
            guard !Task.isCancelled else { return }
            await loadOrders()
        }
    }

    func cancel() {
        loadTask?.cancel()
    }
}

Detection

CHECK: Agent has written a class with closures, delegates, or timers. IF: deinit is not implemented or does not log/clean up. THEN: Add deinit with cleanup. Use Memory Graph Debugger to verify deallocation.

Main Thread Violations

UI Updates Off Main Thread

ANTI_PATTERN: Updating @Observable or @Published properties from a background thread. FIX: Mark the ViewModel with @MainActor or dispatch to main explicitly.

// CORRECT — entire ViewModel on MainActor
@MainActor
@Observable
final class OrderViewModel {
    var orders: [Order] = []

    func load() async {
        orders = try await orderService.fetchOrders()  // Safe — MainActor
    }
}

// CORRECT — explicit dispatch for one property
func load() async {
    let result = try await orderService.fetchOrders()
    await MainActor.run {
        self.orders = result
    }
}

Detecting Main Thread Issues

Enable the Main Thread Checker in Xcode: Scheme > Run > Diagnostics > check "Main Thread Checker". This catches violations at runtime with a purple warning.

SwiftUI View Update Performance

Unnecessary View Rebuilds

ANTI_PATTERN: Placing complex computed properties or side effects in body. FIX: body must be pure — compute values in the ViewModel or use .task.

// WRONG — expensive computation in body
var body: some View {
    let filtered = orders.filter { $0.isActive }  // Runs on every view update
        .sorted(by: { $0.date > $1.date })
    List(filtered) { order in OrderRow(order: order) }
}

// CORRECT — computed in ViewModel
var body: some View {
    List(viewModel.activeOrders) { order in
        OrderRow(order: order)
    }
}

@Observable Granularity

@Observable tracks property access per-view. If a view reads viewModel.orders, it only re-renders when orders changes. But if a view reads viewModel itself (e.g., passing it to a child), it re-renders on ANY property change.

ANTI_PATTERN: Passing the entire ViewModel to child views. FIX: Pass only the specific properties the child needs.

// WRONG — child re-renders on any ViewModel change
OrderRow(viewModel: viewModel)

// CORRECT — child only re-renders when order changes
OrderRow(order: order)

Large Lists

ANTI_PATTERN: Using ForEach with Array for lists of 1000+ items without id:. FIX: Ensure all model types conform to Identifiable. Use LazyVStack for non-List scrollable content.

// CORRECT — lazy loading for large datasets
ScrollView {
    LazyVStack {
        ForEach(orders) { order in
            OrderRow(order: order)
        }
    }
}

Preview Failures

Common Causes

ANTI_PATTERN: Previews that depend on network calls, database state, or environment values not provided. FIX: Use mock data and inject all dependencies explicitly in previews.

#Preview {
    OrderListView(
        viewModel: .init(orderService: MockOrderService())
    )
}

Preview Crash Debugging

CHECK: Agent reports that Xcode Previews are crashing. IF: Preview works in Simulator but not in Canvas. THEN: Check for: 1. Missing @MainActor on the preview provider. 2. Force-unwrapped optionals in the preview data path. 3. Unavailable APIs (preview may use a different OS version).

Simulator vs Device Differences

Known Discrepancies

Behavior Simulator Device
Liquid Glass lensing Approximate Full fidelity
Camera / ARKit Unavailable Required for testing
Push notifications Partial (APNs sandbox) Full
Performance (animations, scrolling) Faster (Mac hardware) Slower (measure here)
Biometric auth (Face ID / Touch ID) Simulated Real
Keychain Separate keychain Device keychain
Network conditions Full bandwidth Variable

CHECK: Agent has completed a feature. IF: Testing was done only on Simulator. THEN: Flag for device testing. Performance and Liquid Glass rendering MUST be verified on hardware.

Swift 6 Concurrency Pitfalls

Data Race Detection

ANTI_PATTERN: Sharing mutable state across actor boundaries without protection. FIX: Use actors for shared mutable state. Mark types as Sendable only if truly safe.

// WRONG — mutable class shared across tasks
class Cache {
    var items: [String: Data] = [:]  // Not thread-safe
}

// CORRECT — actor protects mutable state
actor Cache {
    var items: [String: Data] = [:]

    func get(_ key: String) -> Data? { items[key] }
    func set(_ key: String, data: Data) { items[key] = data }
}

@unchecked Sendable

ANTI_PATTERN: Marking a type @unchecked Sendable to silence compiler warnings. FIX: Fix the actual thread safety issue. @unchecked Sendable is a promise to the compiler — a false promise crashes at runtime.

SwiftData Pitfalls

ANTI_PATTERN: Accessing @Model objects across different ModelContext instances. FIX: Fetch by persistent identifier in the target context.

ANTI_PATTERN: Assuming schema migrations always succeed automatically. FIX: Test migrations with production-like data. Add VersionedSchema and SchemaMigrationPlan for non-trivial changes.

READ_ALSO: wiki/docs/stack/swift-swiftui/persistence.md

App Transport Security

ANTI_PATTERN: Disabling ATS entirely with NSAllowsArbitraryLoads = YES. FIX: Configure per-domain exceptions. Apple rejects apps with blanket ATS disabling.

GE-Specific Pitfalls

  • Never test only on Simulator — Device testing is mandatory before client delivery.
  • Never suppress concurrency warnings — Fix the underlying issue.
  • Never use @unchecked Sendable — If you think you need it, ask martijn or valentin first.
  • Never skip accessibility — VoiceOver must work on every screen.

Cross-References

READ_ALSO: wiki/docs/stack/swift-swiftui/index.md READ_ALSO: wiki/docs/stack/swift-swiftui/architecture.md READ_ALSO: wiki/docs/stack/swift-swiftui/checklist.md