By Abdelrahman Saed — Senior Mobile Engineer. Last updated: 2026-08-10.
Error handling in Flutter has two layers: the domain layer (recoverable failures your code handles gracefully) and the platform layer (unexpected crashes you cannot recover from but must report). Mixing them up — catching everything in one big try/catch or throwing exceptions across layers — leads to bugs that are hard to trace and crashes that go unreported.
At iStoria, we maintain a 99.9% crash-free rate across 5M+ users. The pattern that makes this possible is Either<Failure, T> for recoverable errors and structured crash reporting (Sentry) for everything else. Each layer has a clear responsibility: repositories map exceptions to domain Failures, BLoCs fold over Either, and the platform layer catches the unexpected.
This guide covers the full pattern — from defining Failure types to wiring up async error zones and crash reporting — as we use it in production.
Recoverable errors are part of your business domain. The network went down, the cache was empty, the user entered invalid data. These are expected, the app handles them, and the user sees a friendly message. These should be Either<Failure, T>, not exceptions.
Unrecoverable errors are bugs. A null where there should not be one, an index out of bounds, a state that should never occur. The app cannot handle these gracefully. These should crash (or be caught by an error zone) and be reported to Sentry so you can fix them.
The mistake most teams make is treating all errors as the same kind. They catch every exception in a giant try/catch, suppressing bugs and hiding real crashes. Or they throw exceptions across layers, making the code path unpredictable and untestable.
Either<L, R> from the fpdart (or dartz) package represents a value that is one of two types. By convention, Left holds the failure and Right holds the success value.
// A repository method returns Either<Failure, T>
Future<Either<Failure, List<Lesson>>> getLessons(String courseId);
// The caller MUST handle both cases explicitly:
final result = await repository.getLessons(courseId);
result.fold(
(failure) => emit(LessonError(failure.message)),
(lessons) => emit(LessonLoaded(lessons)),
);
The power of this pattern is that you cannot forget to handle the failure case. With exceptions, a caller can forget the try/catch and the exception propagates unpredictably. With Either, the type system forces you to handle both branches.
Failures are part of the domain. They represent business-meaningful error states:
abstract class Failure {
final String message;
const Failure(this.message);
}
class ServerFailure extends Failure {
final int? statusCode;
const ServerFailure([super.message = 'Server error', this.statusCode]);
}
class CacheFailure extends Failure {
const CacheFailure([super.message = 'Cache error']);
}
class OfflineFailure extends Failure {
const OfflineFailure() : super('You are offline. Please check your connection.');
}
class ValidationFailure extends Failure {
final Map<String, String> fieldErrors;
const ValidationFailure(this.fieldErrors) : super('Validation failed');
}
The key principle: Failures are domain concepts, not infrastructure exceptions. DioError, DriftException, SocketException — these are data-layer concerns. The repository catches them and maps them to domain Failures. The BLoC never sees a DioError.
Not everything should be an Either. Some operations are genuinely exception-based:
1. Programming errors — null dereference, index out of bounds, assertion failures. These should crash and be reported, not be wrapped in Either. 2. Third-party SDKs — some packages throw exceptions. Wrap their calls in your repository and map exceptions to Failures. 3. Truly unexpected states — if a repository method receives a response that violates an invariant, throw. This is a bug, not a recoverable error.
The repository is the translation point. It catches infrastructure exceptions and converts them to domain Failures:
@override
Future<Either<Failure, List<Lesson>>> getLessons(String courseId) async {
if (!await networkInfo.isConnected) {
return const Left(OfflineFailure());
}
try {
final response = await remoteDatasource.fetchLessons(courseId);
return Right(response.map((m) => m.toEntity()).toList());
} on DioError catch (e) {
return Left(ServerFailure(e.message ?? 'Unknown error', e.response?.statusCode));
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
} catch (e) {
// Unexpected — report to Sentry, then return a generic failure
await Sentry.captureException(e);
return Left(UnexpectedFailure(e.toString()));
}
}
The last catch block is important. It catches truly unexpected exceptions, reports them, and returns a failure. This prevents the app from crashing on unexpected exceptions while still surfacing them in Sentry.
Flutter has error zones for isolating async failures. The two zones you need to know:
1. runZonedGuarded — wraps your entire app and catches uncaught async errors. This is where you configure Sentry to report crashes. 2. FlutterError.onError — catches Flutter framework errors (rendering, layout, widget build failures).
In main():
void main() {
runZonedGuarded(() {
WidgetsFlutterBinding.ensureInitialized();
FlutterError.onError = (details) {
FlutterError.presentError(details);
Sentry.captureException(details.exception, stackTrace: details.stack);
};
runApp(MyApp());
}, (error, stack) {
Sentry.captureException(error, stackTrace: stack);
});
}
This ensures that every uncaught error — sync or async, framework or Dart — reaches Sentry. Combined with Either<Failure, T> for recoverable errors, this gives you a comprehensive error strategy: expected failures are handled in the UI, unexpected crashes are reported.
At iStoria, we track crash-free rate as a first-class metric. The target is 99.9% — meaning fewer than 0.1% of sessions crash. Achieving this requires:
Either<Failure, T> on every repository method so recoverable errors never crash the app.lib/
├── core/
│ ├── error/
│ │ ├── failures.dart # domain Failure hierarchy
│ │ └── exceptions.dart # data-layer exception types
│ └── crash/
│ └── crash_reporter.dart # Sentry wrapper, initialized in main()
├── features/
│ └── lesson/
│ ├── data/repositories/
│ │ └── lesson_repository_impl.dart # catches exceptions → maps to Failures
│ └── presentation/
│ └── cubit/lesson_cubit.dart # folds Either → emits states
└── main.dart # runZonedGuarded + FlutterError.onError
// core/error/failures.dart — DOMAIN FAILURES
sealed class Failure {
final String message;
const Failure(this.message);
}
class ServerFailure extends Failure {
final int? statusCode;
const ServerFailure([super.message = 'Server error', this.statusCode]);
}
class CacheFailure extends Failure {
const CacheFailure([super.message = 'Cache error']);
}
class OfflineFailure extends Failure {
const OfflineFailure() : super('You appear to be offline.');
}
class ValidationFailure extends Failure {
final Map<String, String> fieldErrors;
const ValidationFailure(this.fieldErrors) : super('Validation failed');
}
class UnauthorizedFailure extends Failure {
const UnauthorizedFailure() : super('Your session has expired.');
}
class UnexpectedFailure extends Failure {
const UnexpectedFailure(String detail) : super('Something went wrong: $detail');
}
// core/error/exceptions.dart — DATA-LAYER EXCEPTIONS (never cross into domain)
class ServerException implements Exception {
final String message;
final int? statusCode;
ServerException(this.message, [this.statusCode]);
}
class CacheException implements Exception {
final String message;
CacheException(this.message);
}
// data/repositories/lesson_repository_impl.dart — MAPS exceptions → Failures
class LessonRepositoryImpl implements LessonRepository {
final LessonRemoteDatasource remote;
final LessonLocalDatasource local;
final NetworkInfo networkInfo;
LessonRepositoryImpl({
required this.remote,
required this.local,
required this.networkInfo,
});
@override
Future<Either<Failure, List<Lesson>>> getLessons(String courseId) async {
if (!await networkInfo.isConnected) {
// Expected: offline — try cache
return _getFromCacheOrFailure(courseId, const OfflineFailure());
}
try {
final remoteLessons = await remote.fetchLessons(courseId);
await local.cacheLessons(courseId, remoteLessons);
return Right(remoteLessons.map((m) => m.toEntity()).toList());
} on ServerException catch (e) {
// Server failed — try cache before giving up
return _getFromCacheOrFailure(
courseId,
ServerFailure(e.message, e.statusCode),
);
} catch (e, stack) {
// Truly unexpected — report it, return a safe failure
await Sentry.captureException(e, stackTrace: stack);
return Left(UnexpectedFailure(e.toString()));
}
}
Future<Either<Failure, List<Lesson>>> _getFromCacheOrFailure(
String courseId,
Failure fallback,
) async {
try {
final cached = await local.getCachedLessons(courseId);
if (cached.isEmpty) return Left(fallback);
return Right(cached.map((m) => m.toEntity()).toList());
} on CacheException {
return Left(fallback);
}
}
}
// presentation/cubit/lesson_cubit.dart — FOLDS Either → emits states
class LessonCubit extends Cubit<LessonState> {
final LessonRepository repository;
LessonCubit(this.repository) : super(LessonInitial());
Future<void> loadLessons(String courseId) async {
emit(LessonLoading());
final result = await repository.getLessons(courseId);
emit(
result.fold(
(failure) => _mapFailureToState(failure),
(lessons) => LessonLoaded(lessons: lessons),
),
);
}
LessonState _mapFailureToState(Failure failure) {
return switch (failure) {
OfflineFailure() => LessonError(
failure.message,
action: ErrorAction.retry,
),
UnauthorizedFailure() => LessonError(
failure.message,
action: ErrorAction.login,
),
ServerFailure(:final statusCode) when statusCode == 404 =>
LessonError('Lessons not found.', action: ErrorAction.none),
ServerFailure() => LessonError(
failure.message,
action: ErrorAction.retry,
),
_ => LessonError(failure.message),
};
}
}
// main.dart — ZONE GUARD + CRASH REPORTING
void main() {
runZonedGuarded(() {
WidgetsFlutterBinding.ensureInitialized();
FlutterError.onError = (details) {
FlutterError.presentError(details);
Sentry.captureException(details.exception, stackTrace: details.stack);
};
runApp(MyApp());
}, (error, stack) {
// Catches uncaught async errors that slip past try/catch
Sentry.captureException(error, stackTrace: stack);
});
}
Either forces the caller to handle the error case at the type level — the compiler will not let you forget. With try/catch, a caller can forget the catch block and the exception propagates unpredictably. Either makes error handling explicit and part of the function signature, which is especially valuable across module boundaries.
Both work. fpdart is more actively maintained and has better null-safety support. dartz is older and has some null-safety quirks. At iStoria we use fpdart. If you do not want a dependency, you can define your own Result<T> sealed class with Success and Failure variants — it is 20 lines of code and gives you the same pattern without a package.
Wrap your app in runZonedGuarded in main() and set FlutterError.onError. These catch uncaught async errors and Flutter framework errors respectively. Route both to Sentry (or your crash reporter). This covers the 'unexpected' category of errors that should not happen but do — bugs that Either is not designed for.
Available for hire. Abdelrahman Saed is a Senior Mobile Engineer (Flutter) — open to full-time, fractional, contract, or advisory work. Hire me →
Book a 20-minute call · Download the CV (PDF) · See how I work