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.