DEV Community

Cover image for Integrating APIs Seamlessly in Flutter — My Battle-Tested Pattern
Gulshan Yadav
Gulshan Yadav

Posted on Originally published at misar.blog

Integrating APIs Seamlessly in Flutter — My Battle-Tested Pattern

After a dozen production apps, this is the API integration architecture I now use in every Flutter project — dio, interceptors, retry, caching, and error handling in five files.

My first real Flutter app had an API layer that looked like a crime scene: forty-something functions, each one spinning up its own http call, its own error handling, its own hardcoded base URL. Every screen called the network directly. Every bug was a hunt across a different file. Refactoring it took a full week, and I swore I would never write Flutter networking that way again.

Since then I have shipped that lesson across a dozen apps — e-commerce, a logistics tracking dashboard, a booking product, a fintech prototype. The pattern settled into five files that I now drop into every new project and barely touch afterward: the dio client, the auth interceptor, a retry layer, a cache, and typed repositories. This article walks you through each one with working code, then covers the failure modes I keep hitting so you skip the week I lost.

Why dio and Not Plain http

The standard http package is fine for one-off requests. It is not fine for an app with auth, retries, logging, and timeouts, because you end up reimplementing the same plumbing in every function. dio gives you four things out of the box that make the pattern possible:

  • Interceptors — hook into every request and response, which is where auth headers, logging, and token refresh live.
  • Retry logic — pluggable, with per-request control.
  • Timeouts — configurable connect, receive, and send timeouts per client.
  • Response transformation — typed access to JSON without boilerplate.

Add dio and dio_cache_interceptor to pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  dio: ^5.4.0
  dio_cache_interceptor: ^3.5.0
  dio_cache_interceptor_db_store: ^3.2.0
Enter fullscreen mode Exit fullscreen mode

File 1: The Client (Where Every Request Flows)

One dio instance for the whole app. This is the file that owns the base URL, the timeouts, and the interceptors — and it is the reason you will never scatter Uri.parse('https://your-api.com/...') across your screens again.

import 'package:dio/dio.dart';
import 'auth_interceptor.dart';
import 'retry_interceptor.dart';

Dio buildDio() {
  final dio = Dio(
    BaseOptions(
      baseUrl: 'https://your-api.com/api/v1',
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 15),
      sendTimeout: const Duration(seconds: 10),
      headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
    ),
  );
  dio.interceptors.addAll([
    AuthInterceptor(dio),
    RetryInterceptor(dio),
    LogInterceptor(requestBody: false, responseBody: false),
  ]);
  return dio;
}
Enter fullscreen mode Exit fullscreen mode

One detail people miss: timeouts are set here, once, instead of being forgotten per call. And the LogInterceptor in debug builds only — gate it behind a flag or a build check, because response bodies in logs are a security hole on user devices.

File 2: The Auth Interceptor (Tokens Refresh Automatically)

This is the interceptor that saves your app from 401s. It attaches the access token to every outgoing request, and when a 401 comes back, it fires a single refresh request and retries the original call once — so the user never sees an error flash.

class AuthInterceptor extends Interceptor {
  AuthInterceptor(this._dio);
  final Dio _dio;

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
    final token = await TokenStore.readAccessToken();
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode != 401) return handler.next(err);
    try {
      final ok = await _refreshToken();
      if (!ok) {
        await TokenStore.clear();
        handler.reject(err); // route to login
        return;
      }
      final token = await TokenStore.readAccessToken();
      err.requestOptions.headers['Authorization'] = 'Bearer $token';
      final response = await _dio.fetch(err.requestOptions); // retry once
      handler.resolve(response);
    } catch (_) {
      handler.next(err);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Two gotchas that cost me hours each: the refresh request must not itself go through the auth interceptor, or you get an infinite 401 loop — guard it with a flag on the BaseOptions. And never retry a POST on 401 blindly; the retried request can double-submit. Refresh-once-and-give-up is the safe behavior.

File 3: Retry With Exponential Backoff

Network flakiness is a feature of mobile life, not a bug in your code. A user driving through a tunnel will hit timeouts that have nothing to do with your API. The retry interceptor handles the two recoverable cases — timeouts and the 429 rate-limit — with exponential backoff and a cap.

class RetryInterceptor extends Interceptor {
  @override
  Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
    final maxRetries = 3;
    final options = err.requestOptions;
    final retries = options.extra['retryCount'] as int? ?? 0;

    final retryable = err.type == DioExceptionType.connectionTimeout ||
        err.type == DioExceptionType.receiveTimeout ||
        err.type == DioExceptionType.connectionError ||
        err.response?.statusCode == 429 ||
        err.response?.statusCode == 502 ||
        err.response?.statusCode == 503;

    if (!retryable || retries >= maxRetries) return handler.next(err);

    final delayMs = 500 * (1 << retries); // 500ms, 1s, 2s
    await Future<void>.delayed(Duration(milliseconds: delayMs));

    options.extra['retryCount'] = retries + 1;
    try {
      final response = await Dio().fetch(options);
      handler.resolve(response);
    } catch (e) {
      handler.next(err);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The deliberate omissions are as important as the inclusions: no retry on 4xx client errors (a 400 will never succeed on retry), and no retry on idempotency-unknown requests. If your endpoint is not idempotent — think payments — do not blanket-retry it. Retry only what is provably safe to repeat.

File 4: The Cache (Read Paths, Not Write Paths)

Caching is where apps go from "works" to "feels instant." dio_cache_interceptor does the heavy lifting: you register a store and it caches responses in the SQLite-backed DbCacheStore.

final cacheOptions = CacheOptions(
  store: DbCacheStore(databasePath: 'your_app_cache.db'),
  policy: CachePolicy.requestCacheElseNetwork,
  hitCacheOnError: true,
  maxStale: const Duration(minutes: 15),
);

// Attach to the dio build:
dio.interceptors.add(DioCacheInterceptor(options: cacheOptions));

// On a repository call, control it per request:
Future<OrdersResponse> fetchOrders() async {
  final res = await _dio.get('/orders', options: Options(cache: cacheOptions.copyWith(
    policy: CachePolicy.refresh, // force fresh for the money view
  )));
  return OrdersResponse.fromJson(res.data);
}
Enter fullscreen mode Exit fullscreen mode

Three rules I follow: cache reads, never cache writes; use requestCacheElseNetwork for list screens so stale data shows instantly and refreshes in the background; and make the "force refresh" call a deliberate choice per repository, not a global default. Cache invalidation is a feature, not an afterthought — a stale cart total is worse than a slow one.

File 5: Typed Repositories (The Screens Never See dio)

The last file is the one that keeps your UI clean. Screens talk to a repository with typed methods; the repository owns dio, caching, and parsing. The UI never touches Dio, never parses a map, never knows a status code exists.

class OrdersRepository {
  OrdersRepository(this._dio);
  final Dio _dio;

  Future<OrdersResponse> fetchOrders() async { /* ... */ }
  Future<Order> createOrder(CreateOrderInput input) async { /* ... */ }
}

// In the screen, error handling is explicit and typed:
final repository = OrdersRepository(dio);
try {
  final orders = await repository.fetchOrders();
  // render
} on ApiException catch (e) {
  // render e.message — already human-readable, no status codes in UI
} catch (_) {
  // offline or unknown — show retry state
}
Enter fullscreen mode Exit fullscreen mode

Parsing lives in one place. If the API changes its response shape, you edit one parser, not forty call sites. This is the single biggest maintainability win in the whole pattern.

Error Handling: One Format, Everywhere

The pattern is incomplete without a standard error type. Everything the UI sees is a sealed result — success with data, or failure with a message a human can read:

sealed class ApiResult<T> {
  const ApiResult();
}
class ApiSuccess<T> extends ApiResult<T> {
  const ApiSuccess(this.data);
  final T data;
}
class ApiError<T> extends ApiResult<T> {
  const ApiError(this.message, {this.statusCode});
  final String message;
  final int? statusCode;
}

ApiResult<T> parseError(DioException e) {
  final status = e.response?.statusCode;
  final serverMessage = (e.response?.data as Map?)?.containsKey('message') == true
      ? (e.response!.data as Map)['message'] as String
      : null;
  return ApiError(
    serverMessage ?? _friendlyFor(status, e.type),
    statusCode: status,
  );
}
Enter fullscreen mode Exit fullscreen mode

Your repository methods return Future<ApiResult<T>>. Your screens switch on success or error. No try/catch soup, no thrown exceptions leaking to the UI, and no 400 leaking into the user's face.

Testing the API Layer Without Touching the Network

The pattern above has a side benefit that pays for itself on the first refactor: because dio is injected and repositories are plain classes, the whole layer is testable with a mock adapter and zero real network.

final dio = Dio(BaseOptions(baseUrl: 'https://your-api.com/api/v1'));
dio.httpClientAdapter = MockAdapter(
  (request) async {
    if (request.path == '/orders') {
      return ResponseBody.fromString(
        jsonEncode({'orders': [...]}),
        200,
        headers: {'content-type': ['application/json']},
      );
    }
    return ResponseBody.fromString('not found', 404);
  },
);

final repo = OrdersRepository(dio);
final result = await repo.fetchOrders();
expect(result, isA<ApiSuccess<OrdersResponse>>());
Enter fullscreen mode Exit fullscreen mode

You can now test the retry interceptor (return 503 twice, then 200 — assert the repository eventually succeeds), the cache (first call hits the network, second returns instantly from the store), and the auth interceptor (return 401 once, then 200 — assert the refresh path ran exactly once). Writing those three tests once meant the interceptors never broke behind my back again.

Timeouts, Offline States, and the "Loading Forever" Bug

The most common production bug I see in Flutter networking is the infinite spinner: a call that never returns because no timeout was ever configured. If you set timeouts on the shared BaseOptions (10s connect, 15s receive as a sensible default) and your repository surfaces DioExceptionType.connectionError as an explicit offline state, the UI has a decision to make instead of a promise to wait on.

Add an offline check at the repository boundary so a flight-mode user gets an immediate answer instead of a 15-second timeout:

Future<ApiResult<T>> _guardOffline<T>(Future<ApiResult<T>> Function() call) async {
  final hasConnection = await Connectivity().checkConnectivity();
  if (hasConnection == ConnectivityResult.none) {
    return ApiError('You appear to be offline. Check your connection and retry.');
  }
  return call();
}
Enter fullscreen mode Exit fullscreen mode

The UX rule: never leave the user staring at a spinner. A retry button with a clear offline message is a feature; a spinner that never resolves is a bug report waiting to happen.

The Pitfalls I Keep Hitting (So You Do Not)

  1. Token refresh inside the refresh call. Guard the auth interceptor against re-entry or you will loop on 401s until the rate limiter kills you.
  2. Retrying non-idempotent requests. A retried POST /payments is a double charge. Only blanket-retry GET and provably idempotent calls.
  3. Caching money views. Stale prices, balances, or stock levels destroy trust. Force-refresh any view that shows money or availability.
  4. Logging response bodies in production. Tokens, addresses, and personal data land in your log interceptor. Strip them in release builds.
  5. Base URL hardcoded in screens. The moment you need a staging switch, forty files fight you. One BaseOptions, one change.
  6. Timeout defaults that are too generous. The default is no timeout, which means your loading spinner can spin for minutes on a dead connection. Set them, deliberately, per type of call.

The Adoption Checklist

When you wire a new screen to an API, this is the checklist I run:

  • [ ] The call goes through the shared dio instance, never a fresh client
  • [ ] Auth header attached by the interceptor, not hand-rolled per call
  • [ ] 401 path covered: refresh once, retry once, then fail to login
  • [ ] Retry policy decided per request: retryable or not, with backoff
  • [ ] Read path cached with a max-stale window; money views forced fresh
  • [ ] Response parsed in the repository into a typed model
  • [ ] Screen handles ApiSuccess and ApiError, never a raw exception
  • [ ] Logging interceptor stripped in release builds
  • [ ] Base URL and timeouts configured in exactly one file

That one refactoring week in my first Flutter app paid for itself a hundred times over since. The pattern is boring on purpose — the whole point is that adding the twentieth endpoint takes ten minutes because nothing new needs inventing. Networking should be the least interesting part of your app, and with these five files, it finally is.


*Gulshan Yad

Top comments (0)