SuriDevs Logo
Production-Ready Networking in Flutter with DIO - Part 1: Foundation

Production-Ready Networking in Flutter with DIO - Part 1: Foundation

By Sagar Maiyad  Jan 19, 2026

Here's a confession: my first production Flutter app had 47 try-catch blocks scattered across 23 files. Every API call looked like this:

try {
  final response = await dio.get('/users');
  if (response.statusCode == 200) {
    if (response.data['status'] == 'success') {
      // finally, the actual code
    } else {
      // handle API error
    }
  } else {
    // handle HTTP error
  }
} on DioException catch (e) {
  if (e.type == DioExceptionType.connectionTimeout) {
    // handle timeout
  } else if (e.type == DioExceptionType.connectionError) {
    // handle no internet
  }
  // ... 15 more lines
} catch (e) {
  // handle unknown error
}

Copy. Paste. Repeat. Every. Single. Endpoint.

Six months later, a junior dev joined. They asked why the same error handling code existed in 47 places. I didn't have a good answer.

This tutorial is what I wish someone had shown me on day one.

What We're Building

By the end of this 3-part series, your API calls will look like this:

final response = await userRepository.getProfile();

if (response.success) {
  final user = response.data;
  // done
} else {
  showError(response.message);
  // also done
}

No try-catch. No status code checking. No "what if the internet dies" handling at the call site.

All that complexity? It still exists. But it lives in one place, tested once, and never thought about again.

Here's what we'll build across the 3 parts:

Part What We Build Problem It Solves
Part 1 (this one) ApiConfig, ApiResponse, ErrorHandler Configuration mess, inconsistent responses, cryptic errors
Part 2 TokenManager, TokenRefreshLock, DIO Interceptors Auth token chaos, race conditions, random logouts
Part 3 BaseApiService, Repository pattern Repetitive code, type-unsafe responses

Let's start.

Dependencies

# pubspec.yaml
dependencies:
  dio: ^5.9.0
  flutter_riverpod: ^2.6.1
  flutter_secure_storage: ^9.2.2

Not using Riverpod? That's fine. The concepts work with any state management. I'll show Riverpod providers, but you can adapt to GetIt, Provider, or even manual dependency injection.

Step 1: API Configuration

First problem — hardcoded values everywhere:

// Scattered across your codebase
await dio.get('https://api.example.com/api/v1/users');
await dio.post('https://api.example.com/api/v1/login');
// Oops, someone typed 'v2' by mistake
await dio.get('https://api.example.com/api/v2/products');

One typo. One wrong environment. Hours of debugging.

Here's the fix:

// lib/core/network/api_config.dart

class ApiConfig {
  // Environment URLs — change once, affects everything
  static const String _devBaseUrl = 'https://dev-api.example.com';
  static const String _prodBaseUrl = 'https://api.example.com';

  // Dart's compile-time environment variables
  // Run with: flutter run --dart-define=APP_ENV=production
  static const String _environment = String.fromEnvironment(
    'APP_ENV',
    defaultValue: 'development',
  );

  static bool get isProduction => _environment == 'production';
  static String get baseUrl => isProduction ? _prodBaseUrl : _devBaseUrl;
  static String get apiVersion => '/api/v1';
  static String get fullBaseUrl => '$baseUrl$apiVersion';

  // Timeouts — be generous on mobile networks
  static const Duration connectTimeout = Duration(seconds: 30);
  static const Duration receiveTimeout = Duration(seconds: 30);
  static const Duration sendTimeout = Duration(seconds: 30);

  // Default headers for every request
  static const Map<String, String> defaultHeaders = {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  };
}

"Why 30 seconds? That feels long."

Because your users aren't always on WiFi. They're on the subway. In elevators. At that one coffee shop with terrible internet.

I've watched users stare at loading spinners for 25 seconds on 3G — and the request eventually succeeded. A 10-second timeout would have shown them an error for something that was actually working.

30 seconds is the patience threshold. After that, even the most patient user has given up anyway.

"What's String.fromEnvironment doing?"

It reads values passed at compile time. You can build different versions:

# Development (default)
flutter run

# Production
flutter run --dart-define=APP_ENV=production

# Or in your CI/CD
flutter build apk --dart-define=APP_ENV=production

No more commenting/uncommenting URLs. No more "accidentally pushed dev config to production" disasters.

Step 2: Generic API Response

Second problem — every API returns data differently:

// Endpoint A
{"status": "success", "data": {...}}

// Endpoint B
{"success": true, "result": {...}}

// Endpoint C
{"data": {...}, "message": "OK"}

// Endpoint D (after the backend dev had a bad day)
{...} // just raw data, no wrapper

Your parsing code becomes a mess of if statements checking which format this particular endpoint uses.

Here's a response wrapper that handles all of them:

// lib/core/network/api_response.dart

class ApiResponse<T> {
  final bool success;
  final String message;
  final T? data;
  final Map<String, dynamic>? errors;

  ApiResponse({
    required this.success,
    required this.message,
    this.data,
    this.errors,
  });

  factory ApiResponse.fromJson(
    Map<String, dynamic> json,
    T Function(dynamic)? fromJson, {
    int? statusCode,
  }) {
    // HTTP 2xx = success, regardless of body content
    final isHttpSuccess = statusCode != null &&
                          statusCode >= 200 &&
                          statusCode < 300;

    // Also check body for explicit status
    final isBodySuccess = json['status'] == 'success' ||
                          json['success'] == true;

    return ApiResponse<T>(
      success: isHttpSuccess || isBodySuccess,
      message: json['message'] ?? 'Request completed',
      data: fromJson != null ? fromJson(json['data'] ?? json) : json['data'],
      errors: json['errors'],
    );
  }

  factory ApiResponse.error(String message, {Map<String, dynamic>? errors}) {
    return ApiResponse<T>(
      success: false,
      message: message,
      errors: errors,
    );
  }

  factory ApiResponse.success(T? data, {String? message}) {
    return ApiResponse<T>(
      success: true,
      message: message ?? 'Success',
      data: data,
    );
  }
}

"What's that T Function(dynamic)? fromJson parameter?"

That's how we get type-safe responses. Watch:

// Without type safety — you're guessing
final data = response.data; // dynamic, could be anything

// With ApiResponse<User>
final response = ApiResponse<User>.fromJson(
  json,
  (data) => User.fromJson(data), // Now response.data is User, not dynamic
);

Your IDE autocompletes. Your compiler catches mistakes. Life is better.

"Why check both HTTP status AND body status?"

Because APIs are inconsistent. Some return HTTP 200 with {"status": "error"} in the body. Some return HTTP 400 with useful data you still need.

This approach says: "I'll consider it successful if the HTTP status says so, OR if the body says so." It's defensive, and it's saved me from "works on my machine" bugs multiple times.

Step 3: API Response With Headers

Sometimes you need response headers — typically when tokens come back in headers instead of the body:

// lib/core/network/api_response.dart (add this to the same file)

class ApiResponseWithHeaders<T> {
  final ApiResponse<T> response;
  final Map<String, List<String>>? headers;

  ApiResponseWithHeaders({required this.response, this.headers});

  // Convenience getters — no need to dig through response.response.success
  bool get success => response.success;
  String get message => response.message;
  T? get data => response.data;

  // Helper for extracting tokens
  String? getHeader(String name) {
    return headers?[name.toLowerCase()]?.first;
  }
}

Usage:

final result = await authService.login(email, password);

if (result.success) {
  // Some APIs return token in header
  final token = result.getHeader('authorization');
  // Others return in body
  final token = result.data?.accessToken;
}

Step 4: Error Handler

Third problem — your users see this:

DioException [connection timeout]: The connection errored:
Connection timed out after 30000ms

Or worse, the app crashes because you forgot to catch that one specific error type.

Here's an error handler that turns DIO's technical errors into messages humans understand:

// lib/core/network/api_error_handler.dart

import 'package:dio/dio.dart';
import 'api_response.dart';

class ApiErrorHandler {
  static ApiResponse<T> handleError<T>(DioException error) {
    switch (error.type) {
      case DioExceptionType.connectionTimeout:
      case DioExceptionType.sendTimeout:
      case DioExceptionType.receiveTimeout:
        return ApiResponse.error(
          'Connection timed out. Check your internet and try again.',
        );

      case DioExceptionType.connectionError:
        return ApiResponse.error(
          'No internet connection. Please check your network.',
        );

      case DioExceptionType.badResponse:
        return _handleHttpError<T>(error);

      case DioExceptionType.cancel:
        return ApiResponse.error('Request was cancelled.');

      default:
        return ApiResponse.error(
          'Something went wrong. Please try again.',
        );
    }
  }

  static ApiResponse<T> _handleHttpError<T>(DioException error) {
    final statusCode = error.response?.statusCode;
    final responseData = error.response?.data;

    // Try to get the server's error message first
    String serverMessage = _extractErrorMessage(responseData);

    switch (statusCode) {
      case 400:
        return ApiResponse.error(
          serverMessage.isNotEmpty
            ? serverMessage
            : 'Invalid request. Please check your input.',
        );

      case 401:
        // Don't say "unauthorized" — users don't know what that means
        return ApiResponse.error('Please log in to continue.');

      case 403:
        return ApiResponse.error('You don\'t have permission to do this.');

      case 404:
        return ApiResponse.error('The requested item was not found.');

      case 422:
        // Validation errors — server message is usually helpful here
        return ApiResponse.error(
          serverMessage.isNotEmpty
            ? serverMessage
            : 'Please check your input and try again.',
          errors: responseData is Map<String, dynamic>
            ? responseData['errors']
            : null,
        );

      case 429:
        return ApiResponse.error(
          'Too many requests. Please wait a moment and try again.',
        );

      case 500:
      case 502:
      case 503:
        return ApiResponse.error(
          'Our servers are having issues. Please try again later.',
        );

      default:
        return ApiResponse.error(
          serverMessage.isNotEmpty
            ? serverMessage
            : 'An unexpected error occurred.',
        );
    }
  }

  static String _extractErrorMessage(dynamic responseData) {
    if (responseData == null) return '';

    if (responseData is Map<String, dynamic>) {
      // Format: {"message": "Error message"}
      if (responseData.containsKey('message')) {
        final message = responseData['message'];
        if (message is String) return message;
      }

      // Format: {"error": "Error message"}
      if (responseData.containsKey('error')) {
        final error = responseData['error'];
        if (error is String) return error;
      }

      // Format: {"errors": {"email": ["Email is invalid"]}}
      if (responseData.containsKey('errors')) {
        final errors = responseData['errors'];
        if (errors is Map) {
          for (final value in errors.values) {
            if (value is List && value.isNotEmpty) {
              return value.first.toString();
            }
            if (value is String) return value;
          }
        }
      }
    }

    if (responseData is String) {
      return responseData;
    }

    return '';
  }
}

"Why not show technical details?"

Because your users don't care that it was a 502 Bad Gateway. They care that it doesn't work and want to know if they should try again or give up.

Technical Error What User Sees
HTTP 500 Internal Server Error "Our servers are having issues. Please try again later."
DioExceptionType.connectionTimeout "Connection timed out. Check your internet and try again."
HTTP 422 with validation errors The actual validation message from your server

"What about logging the technical details?"

Good question. You should log them — just not show them to users:

static ApiResponse<T> handleError<T>(DioException error) {
  // Log for debugging (in non-production)
  if (!ApiConfig.isProduction) {
    print('API Error: ${error.type}');
    print('Status: ${error.response?.statusCode}');
    print('Data: ${error.response?.data}');
  }

  // Return user-friendly message
  switch (error.type) {
    // ...
  }
}

In production, send these to your crash reporting tool (Sentry, Crashlytics, etc.) instead of printing.

What We Have So Far

lib/
└── core/
    └── network/
        ├── api_config.dart      # Environment, URLs, timeouts
        ├── api_response.dart    # Generic response wrapper
        └── api_error_handler.dart   # DIO errors → human messages

Three files. Clear responsibilities:

File One-Line Purpose
api_config.dart "Where do I send requests and with what defaults?"
api_response.dart "What shape does every API response take?"
api_error_handler.dart "What do I tell users when things break?"

Quick Test

Before moving on, let's verify this works. Create a simple test:

void main() {
  // Test error handling
  final timeoutError = DioException(
    type: DioExceptionType.connectionTimeout,
    requestOptions: RequestOptions(path: '/test'),
  );

  final response = ApiErrorHandler.handleError<String>(timeoutError);

  print(response.success);  // false
  print(response.message);  // "Connection timed out..."

  // Test response parsing
  final json = {
    'status': 'success',
    'message': 'User loaded',
    'data': {'id': 1, 'name': 'John'}
  };

  final parsed = ApiResponse<Map<String, dynamic>>.fromJson(
    json,
    (data) => data as Map<String, dynamic>,
    statusCode: 200,
  );

  print(parsed.success);  // true
  print(parsed.data);     // {id: 1, name: John}
}

If that runs without errors, you're ready for Part 2.

What's Next

In Part 2, we tackle the hard stuff:

  • Token storage that's actually secure (not SharedPreferences)
  • Automatic token refresh that doesn't spam your server
  • Race condition prevention when multiple requests fail simultaneously

That last one is where most tutorials stop. We won't.

Checklist Before Moving On

  • [ ] ApiConfig compiles and fullBaseUrl returns expected value
  • [ ] ApiResponse.fromJson parses your API's actual response format
  • [ ] ApiErrorHandler returns friendly messages for timeout, no internet, and 500 errors
  • [ ] You've decided where to log technical errors (console for now, crash reporting later)

See you in Part 2.


This is Part 1 of a 3-part series:

Flutter DIO Networking API Dart

Author

Sagar Maiyad
Written By
Sagar Maiyad

Sagar Maiyad - Android developer specializing in Kotlin, Jetpack Compose, and modern Android architecture. Sharing practical tutorials and real-world development insights.

View All Posts →

Latest Post

Latest Tags