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.
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 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