Most apps handle errors like this:
catch {
showAlert = true
}
That’s not error handling.
That’s panic UI.
Real apps need:
- automatic retries
- backoff strategies
- partial recovery
- offline fallback
- graceful degradation
- predictable UX under failure
This post shows how to design resilient error recovery systems in SwiftUI — not just error display.
🧠 The Core Principle
Errors are not events.
They are states.
You don’t “show” errors.
You model and recover from them.
🧱 1. Model Errors Explicitly
Never do:
var error: Error?
Instead:
enum LoadState<T> {
case idle
case loading
case success(T)
case failure(AppError)
}
Where:
enum AppError: Error {
case network
case unauthorized
case notFound
case server
case timeout
case unknown
}
This gives you typed recovery paths.
🔄 2. Retry Is a Policy, Not a Button
Define retry strategies:
enum RetryPolicy {
case none
case immediate
case exponentialBackoff(maxRetries: Int)
}
Your service decides:
func retryPolicy(for error: AppError) -> RetryPolicy {
switch error {
case .network, .timeout:
return .exponentialBackoff(maxRetries: 3)
case .server:
return .immediate
default:
return .none
}
}
UI does not decide retry behavior.
⏳ 3. Exponential Backoff Implementation
func retry<T>(
policy: RetryPolicy,
operation: @escaping () async throws -> T
) async throws -> T {
switch policy {
case .none:
return try await operation()
case .immediate:
return try await operation()
case .exponentialBackoff(let max):
var attempt = 0
while true {
do {
return try await operation()
} catch {
attempt += 1
if attempt >= max { throw error }
let delay = pow(2.0, Double(attempt))
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
}
}
}
}
This makes retries:
- predictable
- centralized
- testable
🧩 4. ViewModel Recovery Pipeline
final class FeedViewModel: ObservableObject {
@Published var state: LoadState<[Post]> = .idle
func load() async {
state = .loading
do {
let posts = try await retry(policy: .exponentialBackoff(maxRetries: 3)) {
try await api.fetchPosts()
}
state = .success(posts)
} catch {
state = .failure(map(error))
}
}
}
The ViewModel:
- owns retry logic
- owns error mapping
- exposes state only
🧠 5. Partial Recovery
Not all failures are fatal.
Example:
- profile loads
- avatar fails
Don’t kill the screen.
Model independently:
struct ProfileState {
var user: LoadState<User>
var avatar: LoadState<Image>
}
Recover each independently.
This gives:
- resilient UX
- no blank screens
- graceful degradation
📱 6. UI Patterns for Recovery
Never block the entire UI.
Patterns:
- inline retry button
- pull-to-refresh
- background retry
- optimistic UI
- stale content with banner
Example:
switch state {
case .loading:
ProgressView()
case .failure:
RetryView { Task { await viewModel.load() } }
case .success(let data):
ContentView(data: data)
default:
EmptyView()
}
🧬 7. Offline Recovery
Combine with caching:
if let cached = cache.get() {
state = .success(cached)
}
Then retry in background.
User gets:
- immediate content
- eventual consistency
🧪 8. Testing Recovery Paths
func testRetriesOnTimeout() async {
let api = FailingAPI()
let vm = FeedViewModel(api: api)
await vm.load()
XCTAssertEqual(vm.state, .failure(.timeout))
}
Because everything is modeled, not hacked.
⚠️ 9. Avoid Infinite Retry Loops
Never:
- retry on unauthorized
- retry on 404
- retry on validation errors
Retry only on transient failures.
❌ 10. Common Anti-Patterns
Avoid:
- alerts for every error
- blocking the whole screen
- retry buttons everywhere
- silent failures
- retrying inside view
- retrying without limits
- swallowing errors
These create:
- user frustration
- server abuse
- undefined behavior
🧠 Mental Model
Think:
Request
→ Failure
→ Classification
→ Retry Policy
→ Recovery Path
Not:
“Oops, something went wrong”
🚀 Final Thoughts
A proper recovery system gives you:
- stable UX
- fewer rage quits
- better reviews
- fewer support tickets
- happier users
- calmer engineers
Errors are inevitable.
Bad recovery is optional.
Top comments (0)