
Flutter: Handling API Timeouts Gracefully
Server down? Don't let the user stare at a spinner forever. Learn to set timeouts in http/Dio, implement Retry Logic, and handle errors gracefully.

Server down? Don't let the user stare at a spinner forever. Learn to set timeouts in http/Dio, implement Retry Logic, and handle errors gracefully.
Establishing TCP connection is expensive. Reuse it for multiple requests.

Yellow stripes appear when the keyboard pops up? Learn how to handle layout overflows using resizeToAvoidBottomInset, SingleChildScrollView, and tricks for chat apps.

HTTP is Walkie-Talkie (Over). WebSocket is Phone (Hello). The secret tech behind Chat and Stock Charts.

IP addresses change like home addresses. MAC addresses are like DNA or Fingerprints. Burned into hardware.

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.
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.
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.
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.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.
Catching the timeout is the easy part. Designing the Recovery is hard.
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.
I once set a 3s timeout on a Payment API. The server took 4s to process logic.
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.
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.
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.
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).
Know the difference to give better error messages.
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.
Even if the network fails, your App's logic shouldn't.