Steven's Knowledge

Networking & API Integration

Flutter networking — Dio client, interceptors, token refresh, serialization, error handling, GraphQL, WebSocket, FCM push notifications

Networking & API Integration

The network layer is where most production bugs hide. The guiding principles: one shared client, never touch raw exceptions in the UI, and never trust a payload.

Choosing an HTTP Client

The http package is the official, minimal client. Dio is a fully-featured client with interceptors, cancellation, multipart, and timeouts built in.

ConcernhttpDio
API surfaceMinimal, function-styleRich client object
InterceptorsNone (wrap manually)First-class, ordered chain
Global timeoutsPer-request onlyBaseOptions connect/receive/send
CancellationManual via Client.close()CancelToken per request
Multipart / progressBasicFormData + send/receive progress
Retry / cacheBuild yourselfEcosystem packages (dio_smart_retry, dio_cache_interceptor)
Bundle weightTinyLarger

Pick http for a thin SDK or a package you publish (fewer transitive dependencies). Pick Dio for an app — interceptors, timeouts, and cancellation are things you will need, and reimplementing them on top of http is wasted effort.

Dio Client Setup

Configure timeouts and the base URL on BaseOptions, construct the client once, and share it via your DI container. Never Dio() per request — see Anti-Patterns.

import 'package:dio/dio.dart';

Dio createDio(AppConfig config) {
  final dio = Dio(
    BaseOptions(
      baseUrl: config.apiBaseUrl, // from flavor/env, not hardcoded
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 15),
      sendTimeout: const Duration(seconds: 15),
      headers: {'Accept': 'application/json'},
      // Treat only 2xx as success; let interceptors decide the rest.
      validateStatus: (status) => status != null && status < 300,
    ),
  );

  // Order matters: interceptors run top-to-bottom on request,
  // bottom-to-top on response.
  dio.interceptors.addAll([
    AuthInterceptor(config.tokenStore),
    TokenRefreshInterceptor(dio, config.tokenStore, config.authApi),
    LoggingInterceptor(), // logging last so it sees the final request
  ]);

  return dio;
}

The base URL must come from a build flavor or environment configuration, never a string literal — see the CI/CD page for flavor setup.

Interceptors

Logging (with redaction)

Never log raw Authorization headers, cookies, or PII. Redact before writing.

class LoggingInterceptor extends Interceptor {
  static const _redactedHeaders = {'authorization', 'cookie', 'set-cookie'};

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    final safeHeaders = {
      for (final e in options.headers.entries)
        e.key: _redactedHeaders.contains(e.key.toLowerCase())
            ? '***REDACTED***'
            : e.value,
    };
    dev.log('→ ${options.method} ${options.uri}',
        name: 'http', error: safeHeaders);
    handler.next(options);
  }

  @override
  void onResponse(Response res, ResponseInterceptorHandler handler) {
    dev.log('← ${res.statusCode} ${res.requestOptions.uri}', name: 'http');
    handler.next(res);
  }
}

See the security page for the full token-handling and certificate-pinning guidance.

Auth header injection

class AuthInterceptor extends Interceptor {
  final SecureTokenStore _store;
  AuthInterceptor(this._store);

  @override
  Future<void> onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) async {
    // Allow opt-out for the login/refresh endpoints.
    if (options.extra['skipAuth'] != true) {
      final token = await _store.accessToken;
      if (token != null) {
        options.headers['Authorization'] = 'Bearer $token';
      }
    }
    handler.next(options);
  }
}

Token-refresh interceptor (concurrent 401s)

The hard case: ten requests fire, the access token has expired, all ten get 401 at once. You must refresh once, queue the rest, then retry them with the new token — not trigger ten parallel refreshes.

class TokenRefreshInterceptor extends Interceptor {
  final Dio _dio;
  final SecureTokenStore _store;
  final AuthApi _authApi;

  TokenRefreshInterceptor(this._dio, this._store, this._authApi);

  // Single in-flight refresh shared by all queued requests.
  Future<String?>? _refreshing;

  @override
  Future<void> onError(
    DioException err,
    ErrorInterceptorHandler handler,
  ) async {
    final isAuthError = err.response?.statusCode == 401;
    final alreadyRetried = err.requestOptions.extra['retried'] == true;

    if (!isAuthError || alreadyRetried) {
      return handler.next(err);
    }

    try {
      // Coalesce: the first 401 starts the refresh; the rest await it.
      _refreshing ??= _performRefresh();
      final newToken = await _refreshing;
      _refreshing = null;

      if (newToken == null) {
        return handler.next(err); // refresh failed → propagate (logout)
      }

      // Retry the original request with the fresh token.
      final options = err.requestOptions
        ..headers['Authorization'] = 'Bearer $newToken'
        ..extra['retried'] = true;

      final response = await _dio.fetch(options);
      return handler.resolve(response);
    } catch (_) {
      _refreshing = null;
      return handler.next(err);
    }
  }

  Future<String?> _performRefresh() async {
    final refreshToken = await _store.refreshToken;
    if (refreshToken == null) return null;
    try {
      final tokens = await _authApi.refresh(refreshToken); // skipAuth: true
      await _store.saveTokens(
        accessToken: tokens.access,
        refreshToken: tokens.refresh,
      );
      return tokens.access;
    } catch (_) {
      await _store.clear(); // invalid refresh token → force re-login
      return null;
    }
  }
}

The refresh endpoint must set extra: {'skipAuth': true} so AuthInterceptor does not attach the expired token, and it must never itself enter the refresh path — otherwise a 401 from the refresh call recurses infinitely.

Error Handling

Map DioException to a typed domain error at the data layer so the UI never sees a transport exception. Model it as a sealed union with freezed.

@freezed
sealed class AppError with _$AppError {
  const factory AppError.network() = NetworkError;      // no connectivity
  const factory AppError.timeout() = TimeoutError;
  const factory AppError.unauthorized() = UnauthorizedError;
  const factory AppError.server(int status, String? message) = ServerError;
  const factory AppError.cancelled() = CancelledError;
  const factory AppError.unknown(Object cause) = UnknownError;
}

AppError mapDioException(DioException e) {
  return switch (e.type) {
    DioExceptionType.connectionTimeout ||
    DioExceptionType.sendTimeout ||
    DioExceptionType.receiveTimeout => const AppError.timeout(),
    DioExceptionType.connectionError => const AppError.network(),
    DioExceptionType.cancel => const AppError.cancelled(),
    DioExceptionType.badResponse => switch (e.response?.statusCode) {
        401 => const AppError.unauthorized(),
        final s? => AppError.server(s, _extractMessage(e.response)),
        _ => AppError.unknown(e),
      },
    _ => AppError.unknown(e),
  };
}

Result/Either at the repository boundary

Return a Result (here via fpdart's Either) so callers handle success and failure explicitly — no try/catch leaking into widgets or view models.

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

  Future<Either<AppError, Order>> fetchOrder(String id) async {
    try {
      final res = await _dio.get('/orders/$id');
      return Right(Order.fromJson(res.data as Map<String, dynamic>));
    } on DioException catch (e) {
      return Left(mapDioException(e));
    } catch (e) {
      return Left(AppError.unknown(e)); // e.g. malformed JSON
    }
  }
}

// View model / Bloc consumes it exhaustively:
final result = await repo.fetchOrder(id);
result.match(
  (error) => emit(OrderState.failure(error)),
  (order) => emit(OrderState.loaded(order)),
);

This keeps error handling in the architecture data layer, where it belongs.

Serialization

Use json_serializable for DTOs and freezed for immutable models with unions and copyWith. Both rely on build_runner code generation.

# pubspec.yaml
dependencies:
  freezed_annotation: ^2.4.4
  json_annotation: ^4.9.0
dev_dependencies:
  build_runner: ^2.4.13
  freezed: ^2.5.7
  json_serializable: ^6.8.0
@freezed
class Order with _$Order {
  const factory Order({
    required String id,
    required String title,
    @Default(0) double total,                 // defensive default
    @JsonKey(name: 'created_at') DateTime? createdAt,
    @Default(<LineItem>[]) List<LineItem> items,
  }) = _Order;

  factory Order.fromJson(Map<String, dynamic> json) => _$OrderFromJson(json);
}

Generate the .g.dart / .freezed.dart files:

dart run build_runner build --delete-conflicting-outputs
# or during development:
dart run build_runner watch --delete-conflicting-outputs

Parse defensively. A field documented as required can arrive null, as the wrong type, or absent. Use nullable types plus @Default(...), and convert numeric strings explicitly. A single un-guarded as int cast on a real payload will crash a release build with no recoverable stack for the user.

Cancellation and Retry

CancelToken

Cancel in-flight requests when the user leaves a screen or types a new search query, so stale responses never overwrite fresh UI.

class SearchController {
  final Dio _dio;
  CancelToken? _token;
  SearchController(this._dio);

  Future<List<Result>> search(String query) async {
    _token?.cancel('superseded'); // cancel the previous request
    _token = CancelToken();
    final res = await _dio.get(
      '/search',
      queryParameters: {'q': query},
      cancelToken: _token,
    );
    return (res.data as List).map((e) => Result.fromJson(e)).toList();
  }

  void dispose() => _token?.cancel('disposed');
}

Exponential backoff

Retry only idempotent, transient failures (timeouts, 502/503/504) — never blindly retry a POST that may have succeeded. dio_smart_retry handles backoff and retry-on-status out of the box.

import 'package:dio_smart_retry/dio_smart_retry.dart';

dio.interceptors.add(
  RetryInterceptor(
    dio: dio,
    retries: 3,
    retryDelays: const [
      Duration(seconds: 1),
      Duration(seconds: 2),
      Duration(seconds: 4),
    ],
    // Only retry on transient/idempotent conditions.
    retryEvaluator: DefaultRetryEvaluator({
      DioExceptionType.connectionTimeout,
      DioExceptionType.receiveTimeout,
      DioExceptionType.connectionError,
    }).evaluate,
  ),
);

For richer retry-with-jitter logic and dead-letter queues at the sync layer, see offline-first.

Response Caching

For HTTP-level caching that honors ETag and Cache-Control, add dio_cache_interceptor with a store backend.

import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';

final cacheOptions = CacheOptions(
  store: MemCacheStore(),
  policy: CachePolicy.request,       // respect server cache headers
  hitCacheOnErrorCodes: [500, 503],  // serve stale on server error
  maxStale: const Duration(days: 7),
);
dio.interceptors.add(DioCacheInterceptor(options: cacheOptions));

This is transient HTTP caching only. For durable offline reads, a local database as source of truth, and background sync, use the architecture in offline-first rather than HTTP cache.

File Upload and Download

Use FormData for multipart uploads and the progress callbacks to drive a progress bar.

Future<void> uploadAvatar(File file, void Function(double) onProgress) async {
  final form = FormData.fromMap({
    'avatar': await MultipartFile.fromFile(file.path, filename: 'avatar.jpg'),
    'kind': 'profile',
  });

  await _dio.post(
    '/uploads',
    data: form,
    onSendProgress: (sent, total) {
      if (total > 0) onProgress(sent / total);
    },
  );
}

Future<void> downloadReport(String url, String savePath) async {
  await _dio.download(
    url,
    savePath,
    onReceiveProgress: (received, total) {
      if (total > 0) debugPrint('${(received / total * 100).toStringAsFixed(0)}%');
    },
  );
}

GraphQL

Choose GraphQL when clients need to shape their own queries and avoid over/under-fetching across many screens. graphql_flutter is the widget-friendly client; Ferry adds normalized caching and full code generation for type-safe operations.

final client = GraphQLClient(
  link: AuthLink(getToken: () async => 'Bearer ${await store.accessToken}')
      .concat(HttpLink('https://api.example.com/graphql')),
  cache: GraphQLCache(store: HiveStore()),
);

const ordersQuery = r'''
  query Orders($status: OrderStatus!) {
    orders(status: $status) { id title total }
  }
''';

final result = await client.query(QueryOptions(
  document: gql(ordersQuery),
  variables: const {'status': 'CONFIRMED'},
));

Prefer REST + Dio for simple CRUD APIs; reach for GraphQL when payload shaping and a strongly-typed schema across a large surface justify the tooling overhead.

Realtime

WebSocket

web_socket_channel provides a Stream/Sink API. Always implement reconnection with backoff — mobile sockets drop constantly.

import 'package:web_socket_channel/web_socket_channel.dart';

class LiveFeed {
  WebSocketChannel? _channel;
  int _attempt = 0;

  void connect() {
    _channel = WebSocketChannel.connect(Uri.parse('wss://api.example.com/feed'));
    _channel!.stream.listen(
      _onMessage,
      onDone: _scheduleReconnect,   // server closed or network dropped
      onError: (_) => _scheduleReconnect(),
    );
  }

  void _scheduleReconnect() {
    final delay = Duration(seconds: min(30, 1 << _attempt)); // capped backoff
    _attempt++;
    Future.delayed(delay, connect);
  }

  void _onMessage(dynamic data) {
    _attempt = 0; // reset backoff on a healthy message
    // decode and forward...
  }
}

Server-Sent Events

For one-way server→client streams (notifications, progress), SSE over a long-lived HTTP response is simpler than WebSocket. Dio can stream a response with ResponseType.stream, or use a dedicated SSE package. SSE auto-reconnects per the spec, but you still resume from the Last-Event-ID.

Security Pointer

Two rules belong to every networking layer: pin certificates to defeat proxy interception, and never log tokens or PII. Both are covered in depth — including flutter_secure_storage, certificate pinning via badCertificateCallback, and secret handling — on the security page. Keep the access token in memory and only the refresh token in secure storage.

Push Notifications & Firebase

Firebase Cloud Messaging (FCM) delivers push. On iOS, FCM rides on APNs, so you must enable the Push Notifications capability and upload your APNs auth key to the Firebase console — otherwise iOS pushes silently fail.

dependencies:
  firebase_core: ^3.6.0
  firebase_messaging: ^15.1.3
  flutter_local_notifications: ^17.2.3

Background handler and initialization

The background/terminated handler must be a top-level or static function annotated @pragma('vm:entry-point') — it runs in its own isolate with no access to app state.

@pragma('vm:entry-point')
Future<void> _firebaseBackgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp(); // isolate has no initialized app
  // Persist or process; do NOT touch UI here.
  debugPrint('bg message: ${message.messageId}');
}

Future<void> initMessaging() async {
  await Firebase.initializeApp();
  FirebaseMessaging.onBackgroundMessage(_firebaseBackgroundHandler);

  // 1. Request permission (iOS + Android 13+).
  final settings = await FirebaseMessaging.instance.requestPermission(
    alert: true, badge: true, sound: true,
  );
  if (settings.authorizationStatus == AuthorizationStatus.denied) return;

  // 2. Foreground messages do NOT show a system notification by default —
  //    render one with flutter_local_notifications.
  FirebaseMessaging.onMessage.listen(_showForegroundNotification);

  // 3. Handle taps that opened the app from background/terminated.
  FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap);
  final initial = await FirebaseMessaging.instance.getInitialMessage();
  if (initial != null) _handleNotificationTap(initial); // cold start via tap
}
App stateDelivery callbackSystem notification shown?
ForegroundonMessageNo — you render it yourself
BackgroundOS tray; tap → onMessageOpenedAppYes (notification payload)
TerminatedOS tray; tap → getInitialMessageYes (notification payload)
Data-only (any state)onBackgroundMessage / onMessageNo

Device token management

Send the FCM token to your backend on login, and re-send whenever it refreshes (token rotates on reinstall, restore, or token invalidation).

Future<void> registerDeviceToken(AuthApi api) async {
  final token = await FirebaseMessaging.instance.getToken();
  if (token != null) await api.registerPushToken(token);

  FirebaseMessaging.instance.onTokenRefresh.listen((newToken) {
    api.registerPushToken(newToken); // keep backend in sync
  });
}

Foreground display with flutter_local_notifications

final _local = FlutterLocalNotificationsPlugin();

Future<void> _showForegroundNotification(RemoteMessage message) async {
  final n = message.notification;
  if (n == null) return;
  await _local.show(
    n.hashCode,
    n.title,
    n.body,
    const NotificationDetails(
      android: AndroidNotificationDetails(
        'default_channel', 'General',
        importance: Importance.high, priority: Priority.high,
      ),
      iOS: DarwinNotificationDetails(),
    ),
    payload: message.data['route'], // carry the deep link target
  );
}

Route the notification's data payload through your router instead of pushing imperatively, so the back stack is correct.

void _handleNotificationTap(RemoteMessage message) {
  final route = message.data['route']; // e.g. "/order/42"
  if (route != null) {
    appRouter.go(route); // go_router instance
  }
}

See the routing page for deep-link configuration and go_router setup.

Testing

Inject the Dio instance so tests can swap in a mock adapter — http_mock_adapter intercepts requests without a real server.

import 'package:http_mock_adapter/http_mock_adapter.dart';
import 'package:test/test.dart';

void main() {
  late Dio dio;
  late DioAdapter adapter;
  late OrderRepository repo;

  setUp(() {
    dio = Dio(BaseOptions(baseUrl: 'https://api.test'));
    adapter = DioAdapter(dio: dio);
    repo = OrderRepository(dio);
  });

  test('returns Order on 200', () async {
    adapter.onGet('/orders/42',
        (s) => s.reply(200, {'id': '42', 'title': 'Test', 'total': 9.5}));

    final result = await repo.fetchOrder('42');

    expect(result.isRight(), isTrue);
    result.match((_) => fail('expected success'),
        (o) => expect(o.title, 'Test'));
  });

  test('maps 401 to UnauthorizedError', () async {
    adapter.onGet('/orders/42', (s) => s.reply(401, {'error': 'expired'}));

    final result = await repo.fetchOrder('42');

    expect(result.isLeft(), isTrue);
    result.match((e) => expect(e, isA<UnauthorizedError>()),
        (_) => fail('expected failure'));
  });
}

Anti-Patterns

1. Calling APIs directly in widgets

A widget's build or onPressed should never hold a Dio call. It couples UI to transport, makes the call untestable, and re-fires on every rebuild. Route requests through a repository and a state-management layer, structured per the architecture guide.

2. No timeouts

Without connectTimeout/receiveTimeout, a stalled connection hangs forever and the user stares at a spinner. Always set timeouts on BaseOptions.

3. Swallowing or over-catching errors

// Anti-pattern: silent swallow — the user gets a blank screen, you get no signal
try {
  return await api.fetch();
} catch (_) {
  return [];
}

Map errors to a typed AppError and surface them. A bare catch (_) that returns empty data hides outages and corrupts state.

4. Logging tokens or PII

dev.log(options.headers) dumps the Authorization bearer into logs and crash reports. Redact in the logging interceptor; see the security page.

5. Parsing huge payloads on the UI isolate

jsonDecode of a multi-MB response on the main isolate drops frames. Offload to a background isolate with compute.

final orders = await compute(_parseOrders, res.data as String);

List<Order> _parseOrders(String body) =>
    (jsonDecode(body) as List).map((e) => Order.fromJson(e)).toList();

6. Creating a new Dio per request

// Anti-pattern: throws away connection pooling, interceptors, and config
Future<Order> fetch(String id) async {
  final dio = Dio(); // fresh client every call
  return Order.fromJson((await dio.get('...')).data);
}

Construct one client at startup and inject it everywhere. A per-request Dio loses keep-alive connections and every interceptor (auth, refresh, logging).

7. Hardcoding base URLs

// Anti-pattern
baseUrl: 'https://api.production.example.com'

Hardcoding ships staging traffic to production (or worse). Drive baseUrl from build flavors / --dart-define; see CI/CD.

The network layer is a boundary, not a feature. Centralize it: one Dio client, interceptors for cross-cutting concerns, typed errors at the repository edge, and DTOs validated on parse. Everything above that boundary — view models, widgets — should only ever see domain models and typed results.

On this page