Error Handling
Handling network errors gracefully — from HTTP status codes to user-friendly error messages.
When your app makes network requests, things can go wrong . The server might be down, the user might have no internet, or the request might timeout. Proper error handling is essential for:
- User experience – Show friendly error messages instead of crashing
- Debugging – Log errors for debugging and monitoring
- Recovery – Allow users to retry failed operations
- Reliability – Build apps that fail gracefully
⚠️ The Cost of Poor Error Handling
- ❌ App crashes when a network request fails
- ❌ Users see confusing error messages
- ❌ No way to recover from errors
- ❌ Hard to debug issues in production
Understanding HTTP status codes is crucial for proper error handling. Here are the most common ones:
Here's how to handle different status codes in your Flutter app with user-friendly messages.
import
'dart:convert'
;
import
'package:http/http.dart'
as
http;
class
ApiException
implements
Exception {
final
String message;
final
int
? statusCode;
ApiException
(
this
.message, {
this
.statusCode});
@override
String
toString
() =>
'ApiException: $message'
;
}
class
ApiService
{
final
http.Client client;
ApiService
({http.Client? client}) : client = client ?? http.Client();
Future
<Map<String, dynamic>>
fetchData
() async {
try
{
final
response = await client.get(
Uri.parse(
'https://api.example.com/data'
),
headers: {
'Accept'
:
'application/json'
},
);
// Handle different status codes
switch
(response.statusCode) {
case
200
:
// Success
return
jsonDecode(response.body)
as
Map<String, dynamic>;
case
400
:
// Bad Request
throw
ApiException(
'Invalid request data. Please check your input.'
, statusCode:
400
);
case
401
:
// Unauthorized
throw
ApiException(
'Please log in to continue.'
, statusCode:
401
);
case
403
:
// Forbidden
throw
ApiException(
'You don\'t have permission to access this resource.'
, statusCode:
403
);
case
404
:
// Not Found
throw
ApiException(
'The requested resource was not found.'
, statusCode:
404
);
case
429
:
// Too Many Requests
throw
ApiException(
'Too many requests. Please wait a moment and try again.'
, statusCode:
429
);
case
var
code
when
code >=
500
&& code <
600
:
// Server Errors
throw
ApiException(
'Server error. Please try again later.'
,
statusCode: response.statusCode,
);
default
:
throw
ApiException(
'Unexpected error: ${response.statusCode}'
,
statusCode: response.statusCode,
);
}
}
on
http.ClientException
catch
(e) {
// Network errors (no internet, connection refused, etc.)
throw
ApiException(
'Network error: ${e.message}'
);
}
}
}
Network errors happen when the device can't reach the server. Common causes include:
- No internet connection
- Server is down
- Connection timeout
- DNS resolution failure
class
NetworkErrorHandler
{
static
String
getUserFriendlyMessage
(Object error) {
if
(error
is
ApiException) {
return
error.message;
}
if
(error
is
http.ClientException) {
if
(error.message.contains(
'Failed host lookup'
)) {
return
'Unable to connect to the server. Please check your internet connection.'
;
}
if
(error.message.contains(
'Connection refused'
)) {
return
'The server is not responding. Please try again later.'
;
}
if
(error.message.contains(
'SocketException'
)) {
return
'Network error. Please check your internet connection.'
;
}
if
(error.message.contains(
'Timeout'
)) {
return
'The request timed out. Please check your internet connection.'
;
}
return
'A network error occurred. Please try again.'
;
}
return
'An unexpected error occurred. Please try again.'
;
}
}
⏱️ Timeouts
Always set timeouts for network requests. A request that hangs indefinitely is a poor user experience.
Use
timeout()
on your Future to set a maximum wait time.
final
response = await client.get(url).timeout(
Duration(seconds:
10
),
onTimeout: () =>
throw
ApiException(
'Request timed out'
),
);
Here's how to display errors in your Flutter UI with a
FutureBuilder
.
import
'package:flutter/material.dart'
;
import
'error_handling.dart'
;
class
DataScreen
extends
StatefulWidget {
@override
State<DataScreen>
createState
() => _DataScreenState();
}
class
_DataScreenState
extends
State<DataScreen> {
late
Future<Map<String, dynamic>> futureData;
final
ApiService _apiService = ApiService();
@override
void
initState
() {
super
.initState();
futureData = _apiService.fetchData();
}
void
_retry
() {
setState(() {
futureData = _apiService.fetchData();
});
}
@override
Widget
build
(BuildContext context) {
return
Scaffold(
appBar: AppBar(title: Text(
'Data'
)),
body: FutureBuilder<Map<String, dynamic>>(
future: futureData,
builder: (context, snapshot) {
// --- Loading State ---
if
(snapshot.connectionState == ConnectionState.waiting) {
return
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height:
16
),
Text(
'Loading data...'
),
],
),
);
}
// --- Error State ---
if
(snapshot.hasError) {
final
errorMessage = NetworkErrorHandler.getUserFriendlyMessage(snapshot.error!);
return
Center(
child: Padding(
padding: EdgeInsets.all(
24
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size:
64
,
color: Colors.red.shade400,
),
SizedBox(height:
16
),
Text(
'Something went wrong'
,
style: TextStyle(
fontSize:
20
,
fontWeight: FontWeight.bold,
),
),
SizedBox(height:
8
),
Text(
errorMessage,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.grey.shade600,
fontSize:
16
,
),
),
SizedBox(height:
24
),
ElevatedButton.icon(
onPressed: _retry,
icon: Icon(Icons.refresh),
label: Text(
'Try Again'
),
),
],
),
),
);
}
// --- Data State ---
if
(snapshot.hasData) {
return
Center(
child: Text(
'Data loaded successfully!'
),
);
}
// --- Fallback ---
return
Center(child: Text(
'No data available'
));
},
),
);
}
}
ApiException
class that holds
a user-friendly message and optional status code.
switch
statement or conditionals to
handle different HTTP status codes with appropriate messages.
http.ClientException
to handle
network connectivity issues.
FutureBuilder
to show loading,
error, and data states with user-friendly messages.
✅ Do's
- Always handle loading, error, and data states
- Show user-friendly error messages (not technical jargon)
- Provide a way to retry failed operations
- Log errors for debugging and monitoring
- Set timeouts for network requests
- Check status codes before parsing responses
❌ Don'ts
- Don't ignore errors or swallow exceptions
- Don't show raw error messages (like stack traces) to users
- Don't assume the network is always available
- Don't parse responses without checking status codes
- Don't forget to handle network timeouts
Here's a complete app that demonstrates all error handling concepts with a simulated API.
import
'dart:convert'
;
import
'package:flutter/material.dart'
;
import
'package:http/http.dart'
as
http;
// ----- Custom Exception -----
class
ApiException
implements
Exception {
final
String message;
final
int
? statusCode;
ApiException
(
this
.message, {
this
.statusCode});
@override String
toString
() =>
'ApiException: $message'
;
}
// ----- API Service with Error Handling -----
class
UserService
{
final
http.Client client;
UserService
({http.Client? client}) : client = client ?? http.Client();
Future
<List<dynamic>>
fetchUsers
() async {
try
{
final
response = await client
.get(
Uri.parse(
'https://jsonplaceholder.typicode.com/users'
),
headers: {
'Accept'
:
'application/json'
},
)
.timeout(Duration(seconds:
10
), onTimeout: () {
throw
ApiException(
'Request timed out. Please check your connection.'
);
});
switch
(response.statusCode) {
case
200
:
return
jsonDecode(response.body)
as
List<dynamic>;
case
401
:
throw
ApiException(
'Please log in to view this content.'
, statusCode:
401
);
case
404
:
throw
ApiException(
'User data not found.'
, statusCode:
404
);
case
var
code
when
code >=
500
&& code <
600
:
throw
ApiException(
'Server error. Please try again later.'
,
statusCode: response.statusCode,
);
default
:
throw
ApiException(
'Unexpected error: ${response.statusCode}'
,
statusCode: response.statusCode,
);
}
}
on
http.ClientException
catch
(e) {
throw
ApiException(
'Network error: ${e.message}'
);
}
catch
(e) {
throw
ApiException(
'An unexpected error occurred'
);
}
}
}
// ----- Error Message Helper -----
String
getErrorMessage
(Object error) {
if
(error
is
ApiException) {
return
error.message;
}
return
'An unexpected error occurred. Please try again.'
;
}
// ----- Flutter UI -----
void
main
() => runApp(
MyApp
());
class
MyApp
extends
StatelessWidget {
@override
Widget
build
(BuildContext context) {
return
MaterialApp(
title:
'Error Handling Demo'
,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3:
true
,
),
home: UserListScreen(),
);
}
}
class
UserListScreen
extends
StatefulWidget {
@override
State<UserListScreen>
createState
() => _UserListScreenState();
}
class
_UserListScreenState
extends
State<UserListScreen> {
late
Future<List<dynamic>> futureUsers;
final
UserService _service = UserService();
@override
void
initState
() {
super
.initState();
futureUsers = _service.fetchUsers();
}
void
_retry
() {
setState(() {
futureUsers = _service.fetchUsers();
});
}
@override
Widget
build
(BuildContext context) {
return
Scaffold(
appBar: AppBar(
title: Text(
'👤 Users'
),
centerTitle:
true
,
actions: [
IconButton(
icon: Icon(Icons.refresh),
onPressed: _retry,
tooltip:
'Refresh'
,
),
],
),
body: FutureBuilder<List<dynamic>>(
future: futureUsers,
builder: (context, snapshot) {
// --- Loading ---
if
(snapshot.connectionState == ConnectionState.waiting) {
return
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height:
16
),
Text(
'Loading users...'
),
],
),
);
}
// --- Error ---
if
(snapshot.hasError) {
final
errorMessage = getErrorMessage(snapshot.error!);
return
Center(
child: Padding(
padding: EdgeInsets.all(
24
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size:
64
,
color: Colors.red.shade400,
),
SizedBox(height:
16
),
Text(
'Unable to load data'
,
style: TextStyle(fontSize:
20
, fontWeight: FontWeight.bold),
),
SizedBox(height:
8
),
Text(
errorMessage,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600, fontSize:
16
),
),
SizedBox(height:
24
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton.icon(
onPressed: _retry,
icon: Icon(Icons.refresh),
label: Text(
'Retry'
),
),
SizedBox(width:
12
),
if (snapshot.error
is
ApiException &&
snapshot.error.statusCode ==
401
)
ElevatedButton.icon(
onPressed: () {
// Navigate to login
},
icon: Icon(Icons.login),
label: Text(
'Log In'
),
),
],
),
],
),
),
);
}
// --- Data ---
if
(snapshot.hasData) {
final
users = snapshot.data!;
return
ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) {
final
user = users[index];
return
Card(
margin: EdgeInsets.symmetric(horizontal:
16
, vertical:
4
),
child: ListTile(
leading: CircleAvatar(child: Text((index +
1
).toString())),
title: Text(user[
'name'
] ??
'Unknown'
),
subtitle: Text(user[
'email'
] ??
'No email'
),
),
);
},
);
}
// --- Fallback ---
return
Center(child: Text(
'No data available'
));
},
),
);
}
}
Always check
response.statusCode
before parsing the response body.
A 404 or 500 response may not contain the expected JSON structure.
Use
if (response.statusCode == 200)
before parsing, and throw exceptions
for other status codes.
Don't show
snapshot.error.toString()
directly. Users don't need to see stack traces.
Map errors to user-friendly messages like "Network error. Please check your internet connection."
If a request fails, users should be able to retry without restarting the app.
Add a "Retry" button in the error state that re-fetches the data.
🎯 Key Takeaway
Error handling is essential for a good user experience. Always handle loading, error, and data states. Show user-friendly messages and provide a retry mechanism . Handle HTTP status codes properly and don't forget about network errors and timeouts. Users will forgive errors if they understand what went wrong and can recover.