Mobile App Error Handling and User Recovery Patterns

Every mobile app encounters errors. Networks drop. APIs return unexpected responses. Servers go down during peak traffic. The question is not whether your app will face errors, but whether it handles them gracefully or collapses into cryptic messages and blank screens.

Good error handling is invisible to users. They experience brief loading states, helpful messages, and automatic recovery. Bad error handling makes users feel helpless — staring at “Something went wrong” with no path forward. This guide covers the patterns and strategies that keep your app functional and your users informed, even when things break.

Error Classification

Not all errors are equal. Classify them to determine the appropriate response:

Transient Errors

Network timeouts, temporary server overloads, rate limiting. These resolve on their own with time.

Strategy: Automatic retry with exponential backoff.

User Errors

Invalid input, missing required fields, expired sessions.

Strategy: Clear inline feedback with correction guidance.

System Errors

Server bugs, corrupted data, incompatible API versions.

Strategy: Graceful degradation with support contact options.

Fatal Errors

Corrupted local database, missing critical resources, incompatible OS version.

Strategy: Error screen with recovery instructions (clear data, update app).

Building a Robust Error Handling Architecture

Building a Robust Error Handling Architecture Infographic

Error Types

Define structured error types rather than relying on string messages:

enum AppError: Error {
    case network(NetworkError)
    case api(APIError)
    case validation(ValidationError)
    case storage(StorageError)
    case unknown(Error)

    var userMessage: String {
        switch self {
        case .network(let error):
            return error.userMessage
        case .api(let error):
            return error.userMessage
        case .validation(let error):
            return error.userMessage
        case .storage:
            return "Unable to save your data. Please try again."
        case .unknown:
            return "Something unexpected happened. Please try again."
        }
    }

    var isRetryable: Bool {
        switch self {
        case .network: return true
        case .api(let error): return error.isRetryable
        case .validation: return false
        case .storage: return true
        case .unknown: return false
        }
    }
}

enum NetworkError: Error {
    case noConnection
    case timeout
    case serverUnreachable

    var userMessage: String {
        switch self {
        case .noConnection:
            return "No internet connection. Check your network settings and try again."
        case .timeout:
            return "The request took too long. Please try again."
        case .serverUnreachable:
            return "Our servers are temporarily unavailable. Please try again shortly."
        }
    }
}

enum APIError: Error {
    case unauthorized
    case forbidden
    case notFound
    case conflict(String)
    case rateLimited(retryAfter: TimeInterval)
    case serverError(statusCode: Int)
    case decodingError

    var isRetryable: Bool {
        switch self {
        case .rateLimited, .serverError: return true
        default: return false
        }
    }

    var userMessage: String {
        switch self {
        case .unauthorized:
            return "Your session has expired. Please sign in again."
        case .forbidden:
            return "You do not have permission to perform this action."
        case .notFound:
            return "The requested item could not be found."
        case .conflict(let message):
            return message
        case .rateLimited:
            return "Too many requests. Please wait a moment and try again."
        case .serverError:
            return "Our service is experiencing issues. We are working to fix it."
        case .decodingError:
            return "We received unexpected data. Please update your app."
        }
    }
}

Kotlin Equivalent

sealed class AppError {
    abstract val userMessage: String
    abstract val isRetryable: Boolean

    data class Network(val cause: NetworkError) : AppError() {
        override val userMessage = cause.userMessage
        override val isRetryable = true
    }

    data class Api(val cause: ApiError) : AppError() {
        override val userMessage = cause.userMessage
        override val isRetryable = cause.isRetryable
    }

    data class Validation(val field: String, val message: String) : AppError() {
        override val userMessage = message
        override val isRetryable = false
    }
}

sealed class NetworkError(val userMessage: String) {
    object NoConnection : NetworkError(
        "No internet connection. Check your network settings."
    )
    object Timeout : NetworkError(
        "The request took too long. Please try again."
    )
}

Retry Strategies

Exponential Backoff

For transient errors, retry with increasing delays:

func withRetry<T>(
    maxAttempts: Int = 3,
    initialDelay: TimeInterval = 1.0,
    maxDelay: TimeInterval = 30.0,
    operation: () async throws -> T
) async throws -> T {
    var lastError: Error?
    var delay = initialDelay

    for attempt in 1...maxAttempts {
        do {
            return try await operation()
        } catch let error as AppError where error.isRetryable {
            lastError = error

            if attempt < maxAttempts {
                // Add jitter to prevent thundering herd
                let jitter = Double.random(in: 0...0.5)
                let sleepDuration = min(delay + jitter, maxDelay)

                try await Task.sleep(
                    nanoseconds: UInt64(sleepDuration * 1_000_000_000)
                )
                delay *= 2
            }
        } catch {
            throw error // Non-retryable errors throw immediately
        }
    }

    throw lastError ?? AppError.unknown(
        NSError(domain: "Retry", code: -1)
    )
}

// Usage
func loadProducts() async {
    isLoading = true
    do {
        products = try await withRetry {
            try await productService.fetchAll()
        }
    } catch {
        self.error = error as? AppError ?? .unknown(error)
    }
    isLoading = false
}

Android Retry with Kotlin Coroutines

suspend fun <T> withRetry(
    maxAttempts: Int = 3,
    initialDelayMs: Long = 1000,
    maxDelayMs: Long = 30000,
    block: suspend () -> T
): T {
    var currentDelay = initialDelayMs
    var lastException: Exception? = null

    repeat(maxAttempts) { attempt ->
        try {
            return block()
        } catch (e: RetryableException) {
            lastException = e
            if (attempt < maxAttempts - 1) {
                val jitter = (0..500).random()
                delay(minOf(currentDelay + jitter, maxDelayMs))
                currentDelay *= 2
            }
        }
    }

    throw lastException ?: Exception("Retry failed")
}

UI Error States

Full-Screen Error States

For screens that fail to load entirely:

struct ErrorStateView: View {
    let error: AppError
    let onRetry: (() -> Void)?
    let onDismiss: (() -> Void)?

    var body: some View {
        VStack(spacing: 24) {
            Image(systemName: errorIcon)
                .font(.system(size: 64))
                .foregroundColor(.secondary)

            Text(error.userMessage)
                .font(.body)
                .foregroundColor(.secondary)
                .multilineTextAlignment(.center)
                .padding(.horizontal, 32)

            if error.isRetryable, let onRetry = onRetry {
                Button(action: onRetry) {
                    Label("Try Again", systemImage: "arrow.clockwise")
                }
                .buttonStyle(.borderedProminent)
            }

            if let onDismiss = onDismiss {
                Button("Go Back", action: onDismiss)
                    .foregroundColor(.secondary)
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }

    var errorIcon: String {
        switch error {
        case .network:
            return "wifi.slash"
        case .api(.unauthorized):
            return "lock.fill"
        case .api(.notFound):
            return "magnifyingglass"
        default:
            return "exclamationmark.triangle"
        }
    }
}

Inline Errors

For form validation and partial failures:

struct FormField: View {
    let label: String
    @Binding var text: String
    let error: String?

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(label)
                .font(.caption)
                .foregroundColor(error != nil ? .red : .secondary)

            TextField(label, text: $text)
                .textFieldStyle(.roundedBorder)
                .overlay(
                    RoundedRectangle(cornerRadius: 8)
                        .stroke(error != nil ? Color.red : Color.clear, lineWidth: 1)
                )

            if let error = error {
                Text(error)
                    .font(.caption2)
                    .foregroundColor(.red)
            }
        }
    }
}

Snackbar and Toast Errors

For non-critical errors that should not block the user:

@Composable
fun ErrorSnackbar(
    errorMessage: String?,
    onDismiss: () -> Unit,
    onRetry: (() -> Unit)? = null
) {
    val snackbarHostState = remember { SnackbarHostState() }

    LaunchedEffect(errorMessage) {
        errorMessage?.let { message ->
            val result = snackbarHostState.showSnackbar(
                message = message,
                actionLabel = if (onRetry != null) "Retry" else null,
                duration = SnackbarDuration.Short
            )
            when (result) {
                SnackbarResult.ActionPerformed -> onRetry?.invoke()
                SnackbarResult.Dismissed -> onDismiss()
            }
        }
    }

    SnackbarHost(hostState = snackbarHostState)
}

Offline Error Handling

Mobile apps must handle the transition between online and offline states gracefully:

class NetworkMonitor: ObservableObject {
    @Published var isConnected = true
    @Published var connectionType: ConnectionType = .unknown

    private let monitor = NWPathMonitor()
    private let queue = DispatchQueue(label: "NetworkMonitor")

    init() {
        monitor.pathUpdateHandler = { [weak self] path in
            DispatchQueue.main.async {
                self?.isConnected = path.status == .satisfied
                self?.connectionType = self?.getConnectionType(path) ?? .unknown
            }
        }
        monitor.start(queue: queue)
    }

    private func getConnectionType(_ path: NWPath) -> ConnectionType {
        if path.usesInterfaceType(.wifi) { return .wifi }
        if path.usesInterfaceType(.cellular) { return .cellular }
        return .unknown
    }
}

// Use in views
struct ContentView: View {
    @StateObject private var networkMonitor = NetworkMonitor()

    var body: some View {
        VStack {
            if !networkMonitor.isConnected {
                OfflineBanner()
            }
            // Main content that degrades gracefully offline
        }
    }
}

struct OfflineBanner: View {
    var body: some View {
        HStack {
            Image(systemName: "wifi.slash")
            Text("You are offline. Some features may be limited.")
                .font(.caption)
        }
        .padding(.vertical, 8)
        .padding(.horizontal, 16)
        .frame(maxWidth: .infinity)
        .background(Color.orange.opacity(0.9))
        .foregroundColor(.white)
    }
}

Queue Failed Operations

When network operations fail offline, queue them for later:

class OperationQueue {
    private let storage: UserDefaults

    func enqueue(_ operation: PendingOperation) {
        var pending = loadPendingOperations()
        pending.append(operation)
        savePendingOperations(pending)
    }

    func processQueue() async {
        let pending = loadPendingOperations()

        for operation in pending {
            do {
                try await execute(operation)
                removeFromQueue(operation)
            } catch {
                if !isRetryable(error) {
                    removeFromQueue(operation)
                    notifyFailure(operation, error)
                }
                break // Stop processing on retryable failure
            }
        }
    }
}

Crash Recovery

When your app crashes and relaunches, help users recover their context:

class SessionRecovery {
    func saveState() {
        let state = AppState(
            lastScreen: currentScreen.identifier,
            unsavedData: formData,
            timestamp: Date()
        )
        try? storage.save(state, forKey: "recovery_state")
    }

    func attemptRecovery() -> RecoveryAction? {
        guard let state = try? storage.load(AppState.self, forKey: "recovery_state"),
              state.timestamp.timeIntervalSinceNow > -300 else { // Within 5 minutes
            return nil
        }

        if state.unsavedData != nil {
            return .restoreDraft(state)
        } else {
            return .navigateToLastScreen(state.lastScreen)
        }
    }
}

Error Reporting

Log errors for debugging while respecting user privacy:

class ErrorReporter {
    func report(_ error: AppError, context: ErrorContext) {
        // Strip PII before logging
        let sanitisedContext = ErrorContext(
            screen: context.screen,
            action: context.action,
            timestamp: context.timestamp,
            appVersion: context.appVersion,
            osVersion: context.osVersion,
            // Do NOT include: email, userId, authToken
        )

        // Send to crash reporting service
        crashReporter.logError(error, context: sanitisedContext)

        // Track in analytics
        analytics.track("error_occurred", properties: [
            "type": error.category,
            "screen": context.screen,
            "retryable": error.isRetryable
        ])
    }
}

Testing Error Scenarios

Test error handling as thoroughly as happy paths:

  1. Disable network on device and verify offline behaviour
  2. Simulate slow networks using Network Link Conditioner (iOS) or bandwidth throttling (Android)
  3. Return error responses from mock servers for every API endpoint
  4. Fill disk storage to test storage error handling
  5. Kill and relaunch the app during critical operations
  6. Expire auth tokens to test session recovery

Error handling is not a feature — it is the foundation of a trustworthy app. Users forgive occasional errors. They do not forgive apps that leave them stranded when errors occur.


Need help building resilient mobile apps? Our team at eawesome creates robust applications that handle the real world gracefully.