Flutter: Handling API Timeouts Gracefully
1. "I've Been Watching the Spinner for 3 Minutes."
After launching the app, a review came in: "The app froze. Nothing works."
Checking the logs, the server was temporarily overloaded and not responding. The problem was that my app was coded to "Wait FOREVER until the server responds." To the user, it looked like the app had crashed/frozen.
Nothing is more painful than waiting without a promise. Apps are no exception.
2. The Principle: Default is 'Infinity'
What is the default timeout for Flutter's popular http package?
Surprisingly, it is NULL (None).
Unless the OS or Server cuts the connection, it waits forever.
Dio has defaults, but relying on them without explicit configuration is risky.
Every network request MUST have a Deadline.
3. Solution 1: Setting Timeout in http package
If using http, chain the .timeout() method.
import 'package:http/http.dart' as http;
Future<void> fetchData() async {
try {
final response = await http.get(Uri.parse('https://api.myapp.com'))
.timeout(
const Duration(seconds: 5), // 👈 Wait only 5 seconds
onTimeout: () {
// Logic on timeout
throw TimeoutException('API Server is too slow');
},
);
// Process data...
} on TimeoutException catch (e) {
print('Timeout! Please retry.');
} catch (e) {
print('Other error: $e');
}
}
Now, after 5 seconds, an error fires immediately, unfreezing the app.
4. Solution 2: Setting Timeout in Dio (Recommended)
In production, Dio is preferred. Set global timeouts when creating the Dio instance.
final dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 5), // Handshake timeout
receiveTimeout: const Duration(seconds: 10), // Data receipt timeout
sendTimeout: const Duration(seconds: 5),
));
Future<void> fetchData() async {
try {
final response = await dio.get('/users');
} on DioException catch (e) {
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout) {
// Handle timeout
showRetryDialog();
}
}
}
Tip:
connectTimeout: Establishing connection.receiveTimeout: Waiting for response data. If your SQL query is slow, this hits.
5. Advanced: Interceptors & Retry (Exponential Backoff)
Is showing an error immediately the best UX? Maybe the user just entered an elevator. A smart developer implements Auto-Retry.
Using libraries like dio_smart_retry or custom interceptors:
dio.interceptors.add(RetryInterceptor(
dio: dio,
retries: 3, // Max retries
retryDelays: const [
Duration(seconds: 1), // Wait 1s
Duration(seconds: 2), // Wait 2s
Duration(seconds: 4), // Wait 4s
],
));
The user might not even notice the glitch. Exponential Backoff is polite: it gives the struggling server breathing room before asking again.
6. Deep Dive: UX Strategy
Catching the timeout is the easy part. Designing the Recovery is hard.
- Silent Failure: Show cached stale data if available. (Best UX)
- Snackbar: "Unstable connection." Non-intrusive.
- Retry Button: Full screen error. Required for critical blocking data.
Always verify Idempotency on write operations.
If a POST /purchase times out, DID IT CHARGE? You don't know.
The server might have finished processing 1ms after you disconnected.
Always use an Idempotency Key so re-trying safely doesn't cause double charges.
7. Case Study: The Ghost Request
I once set a 3s timeout on a Payment API. The server took 4s to process logic.
- Client: "Timeout! Show Error."
- Server: "Transaction Complete."
The user saw "Failed", clicked "Pay" again. Double Charge. Lesson: Timeouts are client-side. The server doesn't stop working just because you stopped listening. For critical writes, use Polling (check status) instead of just relying on the response.
8. Deep Dive: Circuit Breaker Pattern
The problem with simple Retry is "DDOSing your own dying server." If the server is down, and 10,000 users retry 3 times instantly, the server will never restart.
Introducing Circuit Breaker. "Failed 5 times in a row? Cut the power. Don't send requests for 1 minute."
bool isCircuitOpen = false;
DateTime? resetTime;
Future<void> request() async {
if (isCircuitOpen && DateTime.now().isBefore(resetTime!)) {
throw Exception('Circuit Open. Fast Fail.');
}
// request... on fail -> increment count -> if count > 5 -> open circuit
}
Fail Fast saves the system infrastructure. Not hitting the network is the fastest response.
9. Case Study: Implementing Exponential Backoff
Sometimes you can't use libraries. Here is a raw implementation.
Future<T> retryWithBackoff<T>(Future<T> Function() apiCall) async {
int attempt = 0;
while (true) {
try {
return await apiCall();
} catch (e) {
if (++attempt > 3) rethrow; // Give up
final delay = Duration(seconds: pow(2, attempt).toInt()); // 2, 4, 8...
print('Retrying in ${delay.inSeconds}s...');
await Future.delayed(delay);
}
}
}
1s -> 2s -> 4s -> 8s. This prevents the Thundering Herd problem where all clients retry at the exact same millisecond.
10. Refactoring Challenge: Cancelling Tokens
Problem: A user types a search query, then hits "Back" to leave the page. The heavy API request keeps running in the background. This wastes battery and server resources.
Challenge:
Use Dio's CancelToken to abort the request when the widget is disposed.
class SearchState extends State<SearchPage> {
CancelToken? _cancelToken;
void search() async {
_cancelToken?.cancel('New search started'); // Cancel previous
_cancelToken = CancelToken();
try {
await dio.get('/search', cancelToken: _cancelToken);
} catch (e) {
if (CancelToken.isCancel(e)) print('Request Canceled');
}
}
@override
void dispose() {
_cancelToken?.cancel('User left page'); // Cancel on unmount
super.dispose();
}
}
This is mandatory for "Autocomplete" features (Debounce + Cancel).
11. Deep Dive: SocketException vs TimeoutException
Know the difference to give better error messages.
- SocketException: No Internet. Creating the TCP socket failed. (Can't find the house)
- TimeoutException: Connected, but no reply in time. (Knocked, but no answer)
- HttpException: Connected, replied, but with 404/500/503. (Door opened, but they yelled at you)
UX Strategy:
- Socket: "Please check your WiFi."
- Timeout: "Server is busy. Try again later."
12. Tip: Fire and Forget
Some requests don't need Timeouts because we don't wait for them. Analytics, Logs, Heartbeats.
// No await!
analytics.sendEvent('clicked_buy');
// Ignore errors completely
api.sendLog().catchError((_) => {});
Don't block the user for background tasks.
11. Summary
- Don't wait forever. Defaults are dangerous.
- Rule of 5-10s. Mobile networks are flaky.
- Auto-Retry. Be resilient to temporary glitches.
- Beware of Double Writes. Timeout != Cancelled.
Even if the network fails, your App's logic shouldn't.