
Swift Exception Handling: Prevent iOS Crashes
Every iOS developer has shipped a build that crashed for a user within minutes of launch. Many of those crashes are preventable the moment you stop treating Swift exception handling and Objective-C exceptions as an afterthought and start designing for them. Unlike languages where an uncaught exception is a rare, isolated event, a single iOS process can be terminated by one uncaught NSException thrown deep inside a framework call — and without the right crash-safety patterns you may never see it coming. This guide covers the full stack of iOS exception handling and crash safety: NSException, NSSetUncaughtExceptionHandler, Swift's Error, Result, and do-catch, plus fatalError, precondition, and assert, and how to turn uncaught-exception reports into fixes you can actually ship.
Why iOS Crashes Are Different
iOS runs on two parallel error universes that do not play by the same rules. Swift uses a typed error system built on the Error protocol, where failures are ordinary values you throw and catch with do-catch. Objective-C — and the Cocoa frameworks that still power much of iOS — relies on NSError for recoverable failures and NSException for conditions that are genuinely exceptional: array-out-of-bounds, unrecognized selectors, force-unwrapping a nil optional bridged across the runtime boundary.
The critical difference is what happens when one goes unhandled. An uncaught Swift error is a compile-time concern — the compiler forces you to handle throws calls — but an uncaught NSException is a runtime event that unwinds the stack and, if nothing catches it, terminates your app. That is why crash logs from iOS frequently show the same signature: a framework API throwing an exception your Swift code never knew could exist. Understanding both models is the foundation of crash safety on iOS.
Swift's Typed Error Model: Error, throw, and do-catch
Swift's error handling guide describes a model where errors are values conforming to Error, typically an enum. The compiler enforces that any throwing call is either caught or propagated, which eliminates an entire class of "forgot to check the error" bugs:
enum NetworkError: Error {
case badURL
case server(status: Int)
case decodingFailed
}
func fetchUser(id: String) throws -> User {
guard let url = URL(string: "https://api.example.com/users/\(id)") else {
throw NetworkError.badURL
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw NetworkError.server(status: (response as? HTTPURLResponse)?.statusCode ?? 0)
}
return try JSONDecoder().decode(User.self, from: data)
}
do {
let user = try await fetchUser(id: "42")
print(user.name)
} catch NetworkError.badURL {
print("Invalid URL")
} catch {
print("Unexpected error: \(error)")
}This model is powerful, but it only protects the paths you control. The moment a call crosses into Objective-C territory — most of UIKit and Foundation — the guarantees weaken, which is why the next sections matter.
Result: Making Errors Explicit Without try
For asynchronous work and pipelines where throwing is awkward, Swift's Result type makes success and failure explicit return values. It is especially useful in completion handlers where a throws annotation cannot express the full flow:
func loadConfig() -> Result<Config, ConfigError> {
guard let data = readFromDisk() else {
return .failure(.missingFile)
}
do {
let config = try JSONDecoder().decode(Config.self, from: data)
return .success(config)
} catch {
return .failure(.decodingFailed)
}
}
switch loadConfig() {
case .success(let config):
apply(config)
case .failure(let error):
report(error)
}The key benefit is that Result forces callers to acknowledge both outcomes, mirroring the compile-time safety of throws while remaining composable with map and flatMap. It is a natural fit for network layers and persistence code where failures are common rather than exceptional.
Objective-C Interop: NSError, NSException, and the Bridging Gap
This is where most iOS crash-safety gaps live. When Swift calls an Objective-C method, the compiler bridges NSError ** out-parameters into throws functions automatically — so a method like save() becomes try save(). That part is seamless. What is not seamless is NSException: Swift has no native mechanism to catch one, and a thrown NSException that crosses into Swift will terminate the process rather than surface as a thrown error.
In Objective-C you can guard risky calls with @try/@catch:
@try {
[self performRiskyOperation];
} @catch (NSException *exception) {
NSLog(@"Caught: %@", exception.reason);
}But you should treat @try/@catch as a last resort, not a design pattern. Catching exceptions is expensive, can mask real bugs, and — critically — some exceptions, like a bad memory access, are unrecoverable no matter what you catch. The right interop strategy is threefold: validate inputs before calling Objective-C APIs, prefer the NSError-based variants of methods when they exist, and use NSSetUncaughtExceptionHandler as the safety net for whatever still slips through.
fatalError, precondition, and assert: Failing Loud and Early
Not all failures should be recovered from. Swift provides three escalating tools for programmer errors, and choosing correctly is a crash-safety decision in its own right. fatalError halts execution immediately in both debug and release builds and is meant for truly unrecoverable states:
guard let delegate = UIApplication.shared.delegate as? AppDelegate else {
fatalError("Missing AppDelegate")
}precondition and preconditionFailure also stop execution in release builds, while assert and assertionFailure are stripped from optimized release builds entirely. Use assert for internal invariants that should only fail during development, and precondition for conditions that must hold even in production, like validating a configuration value:
func configure(index: Int) {
precondition(index >= 0, "Index must be non-negative")
// Safe to continue
}The mistake teams make is sprinkling fatalError liberally in production paths, converting a recoverable data problem into a hard crash. Reserve it for states that indicate a genuine programming error, and let the typed error system handle everything else.
Catching Uncaught Exceptions with NSSetUncaughtExceptionHandler
For Objective-C exceptions that no @try/@catch block intercepts, Foundation provides a process-wide hook: NSSetUncaughtExceptionHandler. Install it early in application(_:didFinishLaunchingWithOptions:) to capture the exception name, reason, and call stack before the process dies:
func installUncaughtExceptionHandler() {
NSSetUncaughtExceptionHandler { exception in
let name = exception.name.rawValue
let reason = exception.reason ?? "no reason"
let stack = exception.callStackSymbols.joined(separator: "\n")
// Persist synchronously before the process terminates
CrashStore.shared.save(name: name, reason: reason, stack: stack)
}
}Three cautions apply here. First, your handler must do its work synchronously and quickly — the process is about to be killed, so asynchronous writes will be lost. Second, this hook only catches NSException; Swift runtime traps like fatalError and force-unwrap crashes bypass it entirely and are reported by the system crash reporter instead. Third, never attempt to recover in the handler — you cannot meaningfully resume execution from an uncaught exception. Its job is capture, not rescue.
The Most Common NSException Triggers in iOS
Most uncaught exceptions in production come from a small set of recurring mistakes. Force-unwrapping a nil optional with ! is the classic: in Swift it traps rather than throws, but when the nil originated from an Objective-C API that returned it for an unexpected reason, the crash signature looks identical to an NSException. Array and dictionary indexing out of bounds is another — NSArray raises NSRangeException while Swift arrays trap, and code that mixes the two, such as passing a Swift-computed index into an Objective-C collection method, can raise where you expected a trap. Key-value coding violations raise NSUnknownKeyException when a key path does not exist, and unrecognized selectors raise NSInvalidArgumentException. Finally, UIKit and Core Data frequently raise NSInternalInconsistencyException when an API is called on the wrong thread or in an invalid state — the same class of bug you chase when a UIKit view controller lifecycle mismatch corrupts the UI.
Crash-Safe Error Propagation Patterns
Crash safety is less about any single API and more about how errors move through your app. The most durable pattern is to centralize a small error boundary at the network and persistence layers, convert everything to your own Error enum, and never let a raw NSError or framework-specific exception leak upward. A second pattern that pays off is fail-soft behavior for non-critical subsystems: if analytics or a secondary data sync fails, log and continue rather than propagating an error that takes down a user-facing screen.
A third, often-overlooked pattern is guarding the Objective-C boundary. Before calling APIs known to throw NSException under bad input — collection indexing, string parsing, KVO key paths — validate your inputs and use the NSError-returning variants where they exist. This is the same discipline you apply when debugging UIKit view controller lifecycle crashes: the goal is to eliminate the states where a framework call can fail catastrophically in the first place.
Testing Your Crash-Safety Net
A crash-safety layer is only as good as your confidence that it fires. Write a small set of unit tests that intentionally trigger each failure mode — throw your NetworkError.server case, return .failure from loadConfig, and verify the error boundary logs and recovers as expected. For the NSSetUncaughtExceptionHandler path, install the handler in a test target and raise a controlled NSException to confirm your synchronous capture writes the name, reason, and stack before the process dies. Keep these tests fast and deterministic so they run on every pull request, and treat a failing safety-net test as a release blocker.
Turning Uncaught Exceptions Into Actionable Reports
Capturing an exception is only half the job. The value comes from seeing every uncaught exception across your entire user base in one place, grouped by signature, with the device, OS version, and breadcrumb trail that led to it. That is where a privacy-first crash reporting platform like Bugspulse changes the game — instead of waiting for a user to email you a screenshot of a crash dialog, you get the exception name, reason, and symbolized stack trace the moment it happens, deduplicated so one widespread crash does not flood your inbox with thousands of identical reports.
Pair that signal with the discipline in this guide and your workflow becomes a loop: the typed error system prevents what it can, NSSetUncaughtExceptionHandler captures what slips through, and the report gives you the exact reason and stack you need to patch the boundary that failed. Most iOS crash-safety problems are not mysterious — they are just invisible until you wire up the right reporting.
If you are ready to stop guessing why your app crashes and start seeing every uncaught exception the moment it happens, create a free Bugspulse account and connect your iOS app in minutes.