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

Common Swift mistakes: a Swift & iOS guide for developers

Habits that cause crashes.

Expona Admin8 min read

Introduction

After 79 days working through Swift and iOS development, you've built features, debugged issues, and shipped code. But some mistakes slip through even experienced teams. Common Swift mistakes: a Swift & iOS guide for developers covers four habits that cause crashes, performance problems, and hard-to-trace bugs in production.

We've seen these patterns across e-commerce checkouts, real-time trading dashboards, and ride-hailing services. Learning to spot and prevent them now saves hours of debugging later.

Force unwrapping optionals

Force unwrapping with ! feels convenient. You know a value exists, so why write extra code? The problem: you're wrong about that assumption more often than you think, and a force unwrap on a nil value crashes your app immediately.

Consider a payment screen that loads user details from the database:

swift
let userID = userDefaults.string(forKey: "userId")!
let user = database.fetchUser(id: userID)
displayPaymentForm(for: user!)

If the UserDefaults key doesn't exist, the first line crashes. If the database query returns nil (network timeout, missing record), the app crashes on the third line. Neither crash provides context—users see a blank screen and uninstall.

Better approaches depend on what should happen when a value is missing:

swift
// Approach 1: Guard and handle the error case
guard let userID = userDefaults.string(forKey: "userId") else {
    showLoginScreen()
    return
}

guard let user = database.fetchUser(id: userID) else {
    showErrorAlert("User not found. Please log in again.")
    return
}

displayPaymentForm(for: user)
swift
// Approach 2: Use if let when you need conditional logic
if let userID = userDefaults.string(forKey: "userId"),
   let user = database.fetchUser(id: userID) {
    displayPaymentForm(for: user)
} else {
    showErrorAlert("Unable to load user information.")
}
swift
// Approach 3: Provide a sensible default
let userID = userDefaults.string(forKey: "userId") ?? "guest"
let user = database.fetchUser(id: userID) ?? User.placeholder()
displayPaymentForm(for: user)

The key is being intentional. Every time you write !, ask: "What happens if this is nil, and is a crash the right behavior?" Ninety-nine percent of the time, the answer is no.

Retain cycles in closures

Closures capture self strongly by default. When a closure holds a strong reference to self, and self holds a strong reference to the closure, you've created a retain cycle. Neither can be deallocated, memory leaks, and the object stays in memory forever.

This is common in completion handlers and network requests:

swift
class CartViewController: UIViewController {
    var items: [CartItem] = []
    
    func loadCart() {
        apiClient.fetchCart { response in
            // Captures self strongly
            self.items = response.items
            self.tableView.reloadData()
        }
    }
}

If the user navigates away before the network request completes, CartViewController should be deallocated. But the closure still holds a strong reference to self, so it stays in memory. The closure completes, updates a view controller that's no longer on screen, and wastes memory.

Use a weak reference with a guard to break the cycle:

swift
class CartViewController: UIViewController {
    var items: [CartItem] = []
    
    func loadCart() {
        apiClient.fetchCart { [weak self] response in
            guard let self else { return }
            self.items = response.items
            self.tableView.reloadData()
        }
    }
}

The [weak self] capture list tells the closure to hold a weak reference. If self is deallocated, the closure's reference becomes nil. The guard check exits safely if self is gone. Memory is freed as expected.

In some cases, you want self to be deallocated after the closure runs (like a one-time setup task). Use [unowned self]:

swift
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [unowned self] in
    self.startAnimation()
}

Be careful: unowned assumes self is still alive when the closure runs. If self is deallocated before that point, you'll crash. Use unowned only when you're certain the object outlives the closure.

For most cases with network requests or delayed operations, [weak self] with a guard is the safe choice.

Updating UI off the main actor

All UIKit and SwiftUI view updates must happen on the main thread. Updating from a background thread can cause visual glitches, crashes, or silent failures. iOS sometimes catches these violations and logs warnings; other times the behavior is unpredictable.

This mistake creeps in with background tasks and concurrent code:

swift
func loadAndDisplayOrders() {
    DispatchQueue.global().async {
        let orders = self.database.fetchOrders()  // Runs on background thread
        self.ordersLabel.text = "Loaded \(orders.count) orders"  // Updates UI off main thread
    }
}

The database query is fast, so the UI update might appear to work most of the time. But race conditions, thread safety issues, and view tearing can occur. In production with real devices and network latency, crashes happen.

Always dispatch UI updates back to the main thread:

swift
func loadAndDisplayOrders() {
    DispatchQueue.global().async {
        let orders = self.database.fetchOrders()
        DispatchQueue.main.async {
            self.ordersLabel.text = "Loaded \(orders.count) orders"
        }
    }
}

With Swift's modern concurrency (async/await), the pattern is cleaner:

swift
func loadAndDisplayOrders() async {
    let orders = await database.fetchOrders()  // Background thread
    await MainActor.run {
        self.ordersLabel.text = "Loaded \(orders.count) orders"
    }
}

Or mark the whole function as running on the main actor:

swift
@MainActor
func loadAndDisplayOrders() async {
    let orders = await database.fetchOrders()
    self.ordersLabel.text = "Loaded \(orders.count) orders"  // Safe on main thread
}

If you're already on the main thread, the DispatchQueue.main.async or MainActor.run call is safe—it just queues the work for the next runloop cycle. The cost is negligible and the benefit is safety.

Ignoring errors with try?

The try? operator silently converts throwing functions to optional return values. It's convenient: if an error occurs, you get nil instead of handling an exception. But convenience often masks problems.

Consider a payment processing function:

swift
func processPayment(cardToken: String, amount: Decimal) {
    let result = try? paymentAPI.charge(token: cardToken, amount: amount)
    
    if result != nil {
        showSuccessMessage("Payment processed")
    } else {
        showErrorMessage("Payment failed")
    }
}

When a payment fails with try?, you have no idea why. Was it a network error? Invalid card? Insufficient funds? Fraud detection? Your error message is useless, and you can't retry with a different strategy or log the real problem for debugging.

Use do/catch to handle specific errors:

swift
func processPayment(cardToken: String, amount: Decimal) {
    do {
        let result = try paymentAPI.charge(token: cardToken, amount: amount)
        showSuccessMessage("Payment processed: \(result.transactionID)")
    } catch let error as PaymentError {
        switch error {
        case .invalidCard:
            showErrorMessage("Your card is invalid. Please check the number.")
        case .insufficientFunds:
            showErrorMessage("Insufficient funds. Please use a different card.")
        case .networkError:
            showErrorMessage("Network error. Please try again.")
        case .fraudDetected:
            showErrorMessage("Transaction blocked. Please contact support.")
        }
    } catch {
        logger.error("Unexpected payment error: \(error)")
        showErrorMessage("An unexpected error occurred. Please try again.")
    }
}

try? is acceptable when you genuinely don't care about the error—for example, parsing optional configuration data:

swift
let config = try? JSONDecoder().decode(Config.self, from: data)

In that case, a sensible default or nil-check is fine. But for operations that can fail in meaningful ways (network, file I/O, database, payment processing), catch the error and handle it explicitly.

Common mistakes

These four patterns show up together in real codebases:

  • Defensive code with force unwrap: A developer wraps a risky operation in a guard, then force-unwraps the result anyway. The guard doesn't protect anything.
  • Retain cycles in view controller closures: Network requests in view controllers almost always leak without [weak self]. Over time, navigation stacks grow full of deallocated objects.
  • Async work without main thread dispatch: Background database queries update UI, then tests fail or users see flashing text and crashes in production.
  • Silent failures with try?: APIs return optional values, callers can't distinguish between "no data" and "error", logging disappears, and you spend hours in production wondering why features stopped working.

The common thread: these mistakes hide problems until the app is in production or under load. Unit tests might pass because they run quickly on a powerful machine. Real devices with network delays, memory pressure, and user navigation patterns expose them.

Key takeaways

  • Replace force unwraps with guard/let or if/let. Be intentional about error cases and provide meaningful fallbacks or error messages.
  • Always use [weak self] in closures that capture self, unless you have a specific reason not to. Guard against nil to avoid crashes.
  • Dispatch UI updates to the main thread explicitly with DispatchQueue.main.async or MainActor.run, even when you think you're already on the main thread.
  • Use do/catch for operations that can fail meaningfully (network, file, database, payment). Log errors and provide users with actionable feedback instead of generic "something went wrong" messages.
  • Test on real devices with network delays and memory constraints. Simulator runs fast and can mask threading, memory, and concurrency issues.
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