DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI Error Recovery & Retry Systems (Resilient Architecture)

Most apps handle errors like this:

catch {
    showAlert = true
}
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

Instead:

enum LoadState<T> {
    case idle
    case loading
    case success(T)
    case failure(AppError)
}
Enter fullscreen mode Exit fullscreen mode

Where:

enum AppError: Error {
    case network
    case unauthorized
    case notFound
    case server
    case timeout
    case unknown
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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))
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

🧬 7. Offline Recovery

Combine with caching:

if let cached = cache.get() {
    state = .success(cached)
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)