← Articles

Handling Firebase errors gracefully in Flutter

By Ann Tech · 27 March 2025

Firebase errors fall into two categories: auth errors (wrong password, expired token), and Firestore/Storage errors (permissions, network, quota). Handling them gracefully means showing appropriate messages to users and logging context for debugging.

Firebase Auth errors

Firebase Auth throws FirebaseAuthException with a code property:

Future<void> signIn(String email, String password) async {
  try {
    await FirebaseAuth.instance.signInWithEmailAndPassword(
      email: email,
      password: password,
    );
  } on FirebaseAuthException catch (e) {
    throw _mapAuthError(e);
  }
}

String _mapAuthError(FirebaseAuthException e) {
  return switch (e.code) {
    'user-not-found' => 'No account found for this email.',
    'wrong-password' => 'Incorrect password.',
    'invalid-email' => 'Please enter a valid email address.',
    'user-disabled' => 'This account has been disabled.',
    'too-many-requests' => 'Too many failed attempts. Try again later.',
    'email-already-in-use' => 'An account with this email already exists.',
    'weak-password' => 'Password must be at least 6 characters.',
    'operation-not-allowed' => 'This sign-in method is not enabled.',
    'network-request-failed' => 'Check your internet connection.',
    String code => 'Sign in failed ($code). Please try again.',
  };
}

Call from a BLoC:

Future<void> _onSignIn(SignInEvent event, Emitter emit) async {
  emit(const AuthLoading());
  try {
    await authRepo.signIn(event.email, event.password);
    emit(const AuthAuthenticated());
  } on String catch (message) {
    emit(AuthError(message: message));
  } catch (e, stack) {
    Sentry.captureException(e, stackTrace: stack);
    emit(const AuthError(message: 'Something went wrong. Please try again.'));
  }
}

Firestore errors

Firestore throws FirebaseException with platform-specific codes:

Future<Order> getOrder(String id) async {
  try {
    final doc = await _db.collection('orders').doc(id).get();
    if (!doc.exists) throw const OrderNotFound();
    return Order.fromFirestore(doc);
  } on FirebaseException catch (e) {
    throw _mapFirestoreError(e);
  }
}

Exception _mapFirestoreError(FirebaseException e) {
  return switch (e.code) {
    'permission-denied' => const PermissionDeniedError(),
    'not-found' => const ResourceNotFoundError(),
    'unavailable' => const ServiceUnavailableError(),
    'deadline-exceeded' => const TimeoutError(),
    'resource-exhausted' => const QuotaExceededError(),
    _ => AppException('Database error: ${e.code}'),
  };
}

Error types sealed class

Use a sealed class for a clean type hierarchy:

sealed class AppError {
  const AppError();
  String get userMessage;
}

class NetworkError extends AppError {
  const NetworkError();
  @override String get userMessage => 'Check your internet connection.';
}

class PermissionError extends AppError {
  const PermissionError();
  @override String get userMessage => 'You don\'t have permission to do that.';
}

class NotFoundError extends AppError {
  const NotFoundError(this.resource);
  final String resource;
  @override String get userMessage => '$resource not found.';
}

class UnknownError extends AppError {
  const UnknownError(this.original);
  final Object original;
  @override String get userMessage => 'Something went wrong. Please try again.';
}

Retrying transient errors

Network errors and Firestore 503s are transient. Retry automatically:

Future<T> withRetry<T>(
  Future<T> Function() operation, {
  int maxAttempts = 3,
  Duration delay = const Duration(seconds: 1),
}) async {
  for (int attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await operation();
    } on FirebaseException catch (e) {
      final isRetryable = ['unavailable', 'deadline-exceeded', 'internal']
          .contains(e.code);
      if (!isRetryable || attempt == maxAttempts) rethrow;
      await Future.delayed(delay * attempt); // Exponential backoff
    }
  }
  throw StateError('Should not reach here');
}

// Usage
final order = await withRetry(() => getOrder(orderId));

Global error boundary

Catch unhandled errors at the app level:

Future<void> main() async {
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

  FlutterError.onError = (details) {
    FlutterError.presentError(details);
    Sentry.captureException(
      details.exception,
      stackTrace: details.stack,
      hint: Hint.withMap({'flutter_error_details': details.toString()}),
    );
  };

  PlatformDispatcher.instance.onError = (error, stack) {
    Sentry.captureException(error, stackTrace: stack);
    return true;
  };

  runApp(const MyApp());
}

Offline handling

Firestore queues writes offline and sends when connected. But reads fail offline without the cache:

try {
  final snap = await _db.collection('orders').doc(id).get();
  return Order.fromFirestore(snap);
} on FirebaseException catch (e) {
  if (e.code == 'unavailable') {
    // Try cache
    final cached = await _db
        .collection('orders')
        .doc(id)
        .get(const GetOptions(source: Source.cache));
    if (cached.exists) return Order.fromFirestore(cached);
    throw const NetworkError();
  }
  rethrow;
}

Common pitfalls

Showing Firebase error codes to users. Error codes like permission-denied are developer-facing. Map them to user-friendly messages.

Not logging unexpected errors. catch (e) that only shows a toast swallows the stack trace. Always log unexpected errors to Sentry or Crashlytics.

Silently swallowing Firestore permission errors. A permission-denied error usually means your security rules are wrong. Log these even if you show a polite message to the user — they're worth investigating.

Sign in to like, dislike, or report.

Handling Firebase errors gracefully in Flutter — ANN Tech