Introduction

Errors are inevitable. Networks fail, servers timeout, APIs change, and users do unexpected things. The difference between a frustrating app and a delightful one often comes down to how gracefully it handles these failures.

Poor error handling creates confusion: cryptic messages, lost data, frozen screens, and users abandoning your app. Good error handling builds trust: clear communication, preserved work, automatic recovery, and users who keep coming back.

This guide covers practical patterns for handling errors in mobile apps. We will build error handling systems that protect users from frustration while giving developers the information needed to fix underlying issues.

Error Classification

Not all

errors are equal. Understanding error types helps determine appropriate responses.

Error Taxonomy

// Classify errors by recoverability and user impact
enum ErrorCategory {
  // Recoverable without user action
  TRANSIENT = 'transient',      // Network blip, timeout
  RATE_LIMITED = 'rate_limited', // Too many requests

  // Recoverable with user action
  AUTH_REQUIRED = 'auth_required',     // Session expired
  VALIDATION = 'validation',           // Invalid input
  PAYMENT_FAILED = 'payment_failed',   // Card declined

  // Potentially recoverable
  OFFLINE = 'offline',          // No network
  SERVER_ERROR = 'server_error', // 5xx responses

  // Not recoverable
  NOT_FOUND = 'not_found',      // Resource deleted
  FORBIDDEN = 'forbidden',      // No permission
  CLIENT_ERROR = 'client_error', // Bug in app
}

interface AppError {
  category: ErrorCategory;
  code: string;
  message: string;           // Technical message for logs
  userMessage: string;       // Human-friendly message
  recoveryAction?: RecoveryAction;
  metadata?: Record<string, any>;
  originalError?: Error;
}

interface RecoveryAction {
  type: 'retry' | 'refresh_auth' | 'go_back' | 'contact_support' | 'update_app';
  label: string;
  action: () => Promise<void>;
}

Error Factory

// Android implementation
sealed class AppError(
    val code: String,
    val userMessage: String,
    val recoveryAction: RecoveryAction? = null
) {
    // Network errors
    class NoConnection : AppError(
        code = "NO_CONNECTION",
        userMessage = "No internet connection. Please check your network and try again.",
        recoveryAction = RecoveryAction.Retry
    )

    class Timeout : AppError(
        code = "TIMEOUT",
        userMessage = "The request took too long. Please try again.",
        recoveryAction = RecoveryAction.Retry
    )

    // Auth errors
    class SessionExpired : AppError(
        code = "SESSION_EXPIRED",
        userMessage = "Your session has expired. Please sign in again.",
        recoveryAction = RecoveryAction.SignIn
    )

    class Unauthorized : AppError(
        code = "UNAUTHORIZED",
        userMessage = "You don't have permission to access this content."
    )

    // Server errors
    class ServerError(details: String? = null) : AppError(
        code = "SERVER_ERROR",
        userMessage = "Something went wrong on our end. Please try again in a moment.",
        recoveryAction = RecoveryAction.Retry
    )

    class ServiceUnavailable : AppError(
        code = "SERVICE_UNAVAILABLE",
        userMessage = "The service is temporarily unavailable. Please try again later.",
        recoveryAction = RecoveryAction.RetryLater
    )

    // Validation errors
    class ValidationError(val field: String, message: String) : AppError(
        code = "VALIDATION_ERROR",
        userMessage = message
    )

    // Generic
    class Unknown(cause: Throwable? = null) : AppError(
        code = "UNKNOWN",
        userMessage = "An unexpected error occurred. Please try again."
    )

    sealed class RecoveryAction {
        object Retry : RecoveryAction()
        object RetryLater : RecoveryAction()
        object SignIn : RecoveryAction()
        object ContactSupport : RecoveryAction()
        object UpdateApp : RecoveryAction()
    }
}

// Error mapper
fun Throwable.toAppError(): AppError {
    return when (this) {
        is UnknownHostException,
        is NoRouteToHostException -> AppError.NoConnection()

        is SocketTimeoutException,
        is TimeoutException -> AppError.Timeout()

        is HttpException -> when (code()) {
            401 -> AppError.SessionExpired()
            403 -> AppError.Unauthorized()
            404 -> AppError.NotFound()
            429 -> AppError.RateLimited()
            in 500..599 -> AppError.ServerError(message())
            else -> AppError.Unknown(this)
        }

        is SSLException -> AppError.NoConnection()

        else -> AppError.Unknown(this)
    }
}

User-Friendly Error Messages

User-Friendly Error Messages Infographic

Message Guidelines

// BAD: Technical jargon
"Error: ECONNREFUSED 127.0.0.1:8080"
"SQLException: constraint violation"
"403 Forbidden"
"null pointer exception"

// GOOD: Human-friendly messages
"We couldn't connect to our servers. Please check your internet connection and try again."
"This email is already registered. Would you like to sign in instead?"
"You don't have permission to view this content."
"Something went wrong. We've been notified and are looking into it."

// Message formula:
// 1. What happened (briefly)
// 2. Why it might have happened (if helpful)
// 3. What the user can do about it

Context-Aware Messages

// iOS implementation
struct ErrorMessageBuilder {

    static func buildMessage(
        for error: AppError,
        context: ErrorContext
    ) -> ErrorPresentation {

        switch (error, context) {
        // Checkout-specific errors
        case (.paymentFailed(let reason), .checkout):
            return ErrorPresentation(
                title: "Payment Failed",
                message: paymentFailureMessage(reason),
                primaryAction: ErrorAction(
                    title: "Try Different Card",
                    action: { context.showPaymentMethods() }
                ),
                secondaryAction: ErrorAction(
                    title: "Contact Support",
                    action: { context.contactSupport() }
                )
            )

        // Network error during checkout - preserve cart
        case (.noConnection, .checkout):
            return ErrorPresentation(
                title: "Connection Lost",
                message: "Don't worry, your cart is saved. Please check your connection and try again.",
                primaryAction: ErrorAction(
                    title: "Retry Payment",
                    action: { context.retryLastAction() }
                )
            )

        // Network error during browsing - less urgent
        case (.noConnection, .browsing):
            return ErrorPresentation(
                title: "You're Offline",
                message: "Connect to the internet to see the latest content.",
                style: .banner // Less intrusive than modal
            )

        // Session expired during important action
        case (.sessionExpired, .checkout):
            return ErrorPresentation(
                title: "Session Expired",
                message: "For your security, please sign in again. Your cart will be waiting.",
                primaryAction: ErrorAction(
                    title: "Sign In",
                    action: { context.presentSignIn(preserveState: true) }
                )
            )

        default:
            return defaultPresentation(for: error)
        }
    }

    private static func paymentFailureMessage(_ reason: PaymentFailureReason) -> String {
        switch reason {
        case .insufficientFunds:
            return "Your card was declined due to insufficient funds. Please try a different payment method."
        case .cardExpired:
            return "Your card has expired. Please update your card details or try a different card."
        case .invalidCVV:
            return "The security code doesn't match. Please check and try again."
        case .fraudSuspected:
            return "Your bank declined this transaction. Please contact them or try a different card."
        default:
            return "Your payment couldn't be processed. Please try a different payment method."
        }
    }
}

Retry Stra

Retry Strategies Infographic tegies

Exponential Backoff

// Android implementation with exponential backoff
class RetryPolicy(
    private val maxRetries: Int = 3,
    private val initialDelayMs: Long = 1000,
    private val maxDelayMs: Long = 30000,
    private val multiplier: Double = 2.0,
    private val jitterFactor: Double = 0.1
) {

    suspend fun <T> execute(
        operation: suspend () -> T,
        shouldRetry: (Throwable) -> Boolean = { it.isRetryable() }
    ): T {
        var currentDelay = initialDelayMs
        var lastException: Throwable? = null

        repeat(maxRetries) { attempt ->
            try {
                return operation()
            } catch (e: Throwable) {
                lastException = e

                if (!shouldRetry(e) || attempt == maxRetries - 1) {
                    throw e
                }

                // Calculate delay with jitter
                val jitter = (Random.nextDouble() * 2 - 1) * jitterFactor * currentDelay
                val delayWithJitter = (currentDelay + jitter).toLong()
                    .coerceIn(0, maxDelayMs)

                Log.d("RetryPolicy", "Attempt ${attempt + 1} failed, retrying in ${delayWithJitter}ms")
                delay(delayWithJitter)

                currentDelay = (currentDelay * multiplier).toLong()
                    .coerceAtMost(maxDelayMs)
            }
        }

        throw lastException ?: IllegalStateException("Retry failed without exception")
    }
}

// Usage
class ApiClient(
    private val retryPolicy: RetryPolicy = RetryPolicy()
) {

    suspend fun fetchProducts(): List<Product> {
        return retryPolicy.execute(
            operation = { api.getProducts() },
            shouldRetry = { error ->
                error is IOException || (error is HttpException && error.code() >= 500)
            }
        )
    }
}

// Extension to determine retryability
fun Throwable.isRetryable(): Boolean {
    return when (this) {
        is IOException -> true
        is SocketTimeoutException -> true
        is HttpException -> code() >= 500 || code() == 429
        else -> false
    }
}

Circuit Breaker

// iOS circuit breaker implementation
actor CircuitBreaker {
    enum State {
        case closed      // Normal operation
        case open        // Failing, reject requests
        case halfOpen    // Testing if service recovered
    }

    private var state: State = .closed
    private var failureCount: Int = 0
    private var lastFailureTime: Date?
    private var successCount: Int = 0

    private let failureThreshold: Int
    private let recoveryTimeout: TimeInterval
    private let successThreshold: Int

    init(
        failureThreshold: Int = 5,
        recoveryTimeout: TimeInterval = 30,
        successThreshold: Int = 3
    ) {
        self.failureThreshold = failureThreshold
        self.recoveryTimeout = recoveryTimeout
        self.successThreshold = successThreshold
    }

    func execute<T>(_ operation: () async throws -> T) async throws -> T {
        // Check if we should allow the request
        switch state {
        case .open:
            if let lastFailure = lastFailureTime,
               Date().timeIntervalSince(lastFailure) > recoveryTimeout {
                state = .halfOpen
                successCount = 0
            } else {
                throw CircuitBreakerError.circuitOpen
            }

        case .halfOpen, .closed:
            break
        }

        do {
            let result = try await operation()
            recordSuccess()
            return result
        } catch {
            recordFailure()
            throw error
        }
    }

    private func recordSuccess() {
        failureCount = 0

        switch state {
        case .halfOpen:
            successCount += 1
            if successCount >= successThreshold {
                state = .closed
            }
        case .closed, .open:
            break
        }
    }

    private func recordFailure() {
        failureCount += 1
        lastFailureTime = Date()

        if failureCount >= failureThreshold {
            state = .open
        }
    }
}

enum CircuitBreakerError: Error {
    case circuitOpen
}

// Usage
class ProductService {
    private let circuitBreaker = CircuitBreaker()
    private let api: ProductAPI

    func fetchProducts() async throws -> [Product] {
        do {
            return try await circuitBreaker.execute {
                try await api.getProducts()
            }
        } catch CircuitBreakerError.circuitOpen {
            // Fall back to cached data
            return try await loadCachedProducts()
        }
    }
}

Graceful Degradation

Offline-First Patterns

// Android: Repository with offline support
class ProductRepository(
    private val remoteDataSource: ProductRemoteDataSource,
    private val localDataSource: ProductLocalDataSource,
    private val connectivityMonitor: ConnectivityMonitor
) {

    fun getProducts(): Flow<Resource<List<Product>>> = flow {
        // Emit cached data immediately
        val cached = localDataSource.getProducts()
        if (cached.isNotEmpty()) {
            emit(Resource.Success(cached, source = DataSource.CACHE))
        } else {
            emit(Resource.Loading())
        }

        // Try to fetch fresh data
        if (connectivityMonitor.isConnected()) {
            try {
                val remote = remoteDataSource.getProducts()
                localDataSource.saveProducts(remote)
                emit(Resource.Success(remote, source = DataSource.NETWORK))
            } catch (e: Exception) {
                if (cached.isEmpty()) {
                    emit(Resource.Error(e.toAppError()))
                } else {
                    // Have cached data, show warning but don't fail
                    emit(Resource.Success(
                        cached,
                        source = DataSource.CACHE,
                        warning = "Showing cached data. Pull to refresh."
                    ))
                }
            }
        } else if (cached.isEmpty()) {
            emit(Resource.Error(AppError.NoConnection()))
        }
    }

    suspend fun createProduct(product: Product): Result<Product> {
        return if (connectivityMonitor.isConnected()) {
            try {
                val created = remoteDataSource.createProduct(product)
                localDataSource.saveProduct(created)
                Result.success(created)
            } catch (e: Exception) {
                Result.failure(e)
            }
        } else {
            // Queue for later sync
            localDataSource.queuePendingAction(
                PendingAction.CreateProduct(product)
            )
            Result.success(product.copy(syncStatus = SyncStatus.PENDING))
        }
    }
}

sealed class Resource<T> {
    data class Loading<T>(val progress: Float? = null) : Resource<T>()

    data class Success<T>(
        val data: T,
        val source: DataSource = DataSource.NETWORK,
        val warning: String? = null
    ) : Resource<T>()

    data class Error<T>(
        val error: AppError,
        val cachedData: T? = null
    ) : Resource<T>()
}

enum class DataSource { NETWORK, CACHE }

Feature Degradation

// iOS: Graceful feature degradation
class FeatureAvailability {
    private let networkMonitor: NetworkMonitor
    private let featureFlags: FeatureFlagService

    func checkFeature(_ feature: Feature) -> FeatureStatus {
        // Check if feature is enabled
        guard featureFlags.isEnabled(feature) else {
            return .disabled(reason: "Feature not available in your region")
        }

        // Check network requirements
        if feature.requiresNetwork && !networkMonitor.isConnected {
            return .degraded(
                fallback: feature.offlineFallback,
                reason: "Limited functionality while offline"
            )
        }

        // Check capability requirements
        if let missingCapability = feature.requiredCapabilities.first(where: { !$0.isAvailable }) {
            return .unavailable(
                reason: "Requires \(missingCapability.displayName)"
            )
        }

        return .available
    }
}

enum FeatureStatus {
    case available
    case degraded(fallback: FeatureFallback, reason: String)
    case disabled(reason: String)
    case unavailable(reason: String)
}

struct FeatureFallback {
    let title: String
    let action: () -> Void
}

// Usage in UI
struct ProductSearchView: View {
    @StateObject var viewModel: ProductSearchViewModel
    let featureAvailability: FeatureAvailability

    var body: some View {
        VStack {
            SearchBar(text: $viewModel.searchQuery)

            switch featureAvailability.checkFeature(.search) {
            case .available:
                SearchResultsList(results: viewModel.results)

            case .degraded(let fallback, let reason):
                VStack {
                    WarningBanner(message: reason)
                    // Show cached/local results
                    LocalSearchResults(query: viewModel.searchQuery)
                }

            case .disabled(let reason), .unavailable(let reason):
                EmptyStateView(
                    icon: "magnifyingglass.circle",
                    title: "Search Unavailable",
                    message: reason
                )
            }
        }
    }
}

Error Reporting and Analytics

Structured Error Logging

// Error reporting service
interface ErrorReport {
  errorId: string;
  timestamp: Date;
  error: {
    code: string;
    message: string;
    category: ErrorCategory;
    stackTrace?: string;
  };
  context: {
    screen: string;
    action: string;
    userId?: string;
    sessionId: string;
  };
  device: {
    platform: 'ios' | 'android';
    osVersion: string;
    appVersion: string;
    deviceModel: string;
  };
  network: {
    type: 'wifi' | 'cellular' | 'none';
    effectiveType?: '4g' | '3g' | '2g' | 'slow-2g';
  };
  metadata?: Record<string, any>;
}

class ErrorReporter {
  private queue: ErrorReport[] = [];
  private readonly maxQueueSize = 100;

  async report(error: AppError, context: ErrorContext): Promise<void> {
    const report: ErrorReport = {
      errorId: generateId(),
      timestamp: new Date(),
      error: {
        code: error.code,
        message: error.message,
        category: error.category,
        stackTrace: error.originalError?.stack,
      },
      context: {
        screen: context.currentScreen,
        action: context.lastAction,
        userId: context.userId,
        sessionId: context.sessionId,
      },
      device: await this.getDeviceInfo(),
      network: await this.getNetworkInfo(),
      metadata: error.metadata,
    };

    // Don't report user errors (validation, etc.)
    if (this.shouldReport(error)) {
      await this.sendReport(report);
    }

    // Always log locally for debugging
    this.logLocally(report);
  }

  private shouldReport(error: AppError): boolean {
    // Don't report expected user errors
    const skipCategories = [
      ErrorCategory.VALIDATION,
      ErrorCategory.AUTH_REQUIRED, // Expected flow
      ErrorCategory.OFFLINE, // User's network
    ];

    return !skipCategories.includes(error.category);
  }

  private async sendReport(report: ErrorReport): Promise<void> {
    try {
      await fetch('/api/errors', {
        method: 'POST',
        body: JSON.stringify(report),
      });
    } catch {
      // Queue for later if reporting fails
      this.queue.push(report);
      if (this.queue.length > this.maxQueueSize) {
        this.queue.shift(); // Remove oldest
      }
    }
  }
}

Analytics Integration

// Android: Error analytics
class ErrorAnalytics(
    private val analytics: AnalyticsClient
) {

    fun trackError(error: AppError, context: ErrorContext) {
        analytics.track("error_occurred", mapOf(
            "error_code" to error.code,
            "error_category" to error.category.name,
            "screen" to context.screen,
            "action" to context.action,
            "is_retryable" to error.isRetryable,
            "has_cached_data" to context.hasCachedData
        ))
    }

    fun trackErrorRecovery(
        error: AppError,
        recoveryMethod: String,
        success: Boolean
    ) {
        analytics.track("error_recovery", mapOf(
            "error_code" to error.code,
            "recovery_method" to recoveryMethod,
            "success" to success
        ))
    }

    fun trackRetryAttempt(
        error: AppError,
        attemptNumber: Int,
        success: Boolean
    ) {
        analytics.track("retry_attempt", mapOf(
            "error_code" to error.code,
            "attempt_number" to attemptNumber,
            "success" to success
        ))
    }

    // Track error patterns for alerts
    fun trackErrorRate(endpoint: String, errorRate: Float) {
        if (errorRate > ERROR_RATE_THRESHOLD) {
            analytics.track("high_error_rate", mapOf(
                "endpoint" to endpoint,
                "error_rate" to errorRate
            ))
        }
    }

    companion object {
        private const val ERROR_RATE_THRESHOLD = 0.05f // 5%
    }
}

UI Error Presentation

Error UI Components

// iOS: Reusable error views
struct ErrorView: View {
    let error: AppError
    let retryAction: (() -> Void)?
    let dismissAction: (() -> Void)?

    var body: some View {
        VStack(spacing: 16) {
            Image(systemName: iconName)
                .font(.system(size: 48))
                .foregroundColor(.secondary)

            Text(error.userMessage)
                .font(.body)
                .multilineTextAlignment(.center)
                .foregroundColor(.primary)

            if let recovery = error.recoveryAction {
                Button(action: { recovery.action() }) {
                    Text(recovery.label)
                        .font(.headline)
                }
                .buttonStyle(.borderedProminent)
            }

            if let retry = retryAction {
                Button(action: retry) {
                    Label("Try Again", systemImage: "arrow.clockwise")
                }
                .buttonStyle(.bordered)
            }
        }
        .padding(24)
    }

    private var iconName: String {
        switch error.category {
        case .offline:
            return "wifi.slash"
        case .authRequired:
            return "lock.fill"
        case .serverError:
            return "exclamationmark.icloud"
        case .notFound:
            return "questionmark.folder"
        default:
            return "exclamationmark.triangle"
        }
    }
}

// Inline error for form fields
struct InlineError: View {
    let message: String

    var body: some View {
        HStack(spacing: 4) {
            Image(systemName: "exclamationmark.circle.fill")
                .foregroundColor(.red)
                .font(.caption)

            Text(message)
                .font(.caption)
                .foregroundColor(.red)
        }
    }
}

// Toast/snackbar for transient errors
struct ErrorToast: View {
    let message: String
    let action: ErrorAction?
    @Binding var isPresented: Bool

    var body: some View {
        HStack {
            Text(message)
                .font(.subheadline)
                .foregroundColor(.white)

            Spacer()

            if let action = action {
                Button(action.title) {
                    action.handler()
                }
                .font(.subheadline.bold())
                .foregroundColor(.white)
            }

            Button {
                isPresented = false
            } label: {
                Image(systemName: "xmark")
                    .foregroundColor(.white.opacity(0.8))
            }
        }
        .padding()
        .background(Color.red.opacity(0.9))
        .cornerRadius(8)
        .padding(.horizontal)
    }
}

Error State Management

// Android: ViewModel error handling
@HiltViewModel
class ProductListViewModel @Inject constructor(
    private val repository: ProductRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow<ProductListUiState>(ProductListUiState.Loading)
    val uiState: StateFlow<ProductListUiState> = _uiState

    private val _errorEvent = MutableSharedFlow<ErrorEvent>()
    val errorEvent: SharedFlow<ErrorEvent> = _errorEvent

    init {
        loadProducts()
    }

    fun loadProducts() {
        viewModelScope.launch {
            repository.getProducts()
                .collect { resource ->
                    _uiState.value = when (resource) {
                        is Resource.Loading -> ProductListUiState.Loading

                        is Resource.Success -> ProductListUiState.Success(
                            products = resource.data,
                            isFromCache = resource.source == DataSource.CACHE,
                            warning = resource.warning
                        )

                        is Resource.Error -> {
                            if (resource.cachedData != null) {
                                // Have cached data, show warning
                                _errorEvent.emit(ErrorEvent.Toast(resource.error.userMessage))
                                ProductListUiState.Success(
                                    products = resource.cachedData,
                                    isFromCache = true,
                                    warning = "Showing cached data"
                                )
                            } else {
                                ProductListUiState.Error(resource.error)
                            }
                        }
                    }
                }
        }
    }

    fun retry() {
        loadProducts()
    }

    fun dismissError() {
        // Return to previous state if possible
    }
}

sealed class ProductListUiState {
    object Loading : ProductListUiState()

    data class Success(
        val products: List<Product>,
        val isFromCache: Boolean = false,
        val warning: String? = null
    ) : ProductListUiState()

    data class Error(val error: AppError) : ProductListUiState()
}

sealed class ErrorEvent {
    data class Toast(val message: String) : ErrorEvent()
    data class Dialog(val error: AppError) : ErrorEvent()
    data class Navigate(val destination: String) : ErrorEvent()
}

Conclusion

Error handling is user experience design. Every error is an opportunity to build trust or lose it. The patterns in this guide help you:

  1. Classify errors appropriately to determine the right response
  2. Communicate clearly with human-friendly messages
  3. Recover automatically when possible with retry strategies
  4. Degrade gracefully when features are unavailable
  5. Learn continuously through proper error reporting

Start with the basics: clear error messages and simple retry logic. Add sophistication as you learn which errors affect your users most. Monitor error rates and recovery success to continuously improve.

The best error handling is invisible to users. They never see errors because the app handles failures gracefully, or when errors must surface, the path forward is always clear.


Building resilient mobile apps for Australian users? We have implemented error handling systems that maintain 99.9% user-perceived availability. Contact us to discuss your reliability requirements.