Skip to main content

Networking and serialization

Talking to a REST backend with http or dio, turning JSON into typed models, and modelling failure so the UI can render it.

Contents

Choosing a client

PackageUse when
httpA handful of endpoints, no interceptors, minimal dependency footprint
dioInterceptors, cancellation, upload/download progress, per-request timeouts, structured errors
flutter pub add http
# or
flutter pub add dio

Both are fine. dio earns its place as soon as you need an auth interceptor or request cancellation, which is most real apps.

http

import 'dart:convert';
import 'package:http/http.dart' as http;

final response = await http
.get(Uri.parse('https://api.example.com/products'))
.timeout(const Duration(seconds: 10));

if (response.statusCode != 200) {
throw HttpException('Unexpected status ${response.statusCode}');
}

final decoded = jsonDecode(response.body) as List<Object?>;

Reuse a http.Client for multiple requests so connections are pooled, and close it when done:

final client = http.Client();
try {
// ... several requests
} finally {
client.close();
}

dio

final dio = Dio(
BaseOptions(
baseUrl: 'https://api.example.com',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 20),
headers: {'Accept': 'application/json'},
),
);

final response = await dio.get<List<dynamic>>('/products',
queryParameters: {'page': 1, 'size': 20});

final products = response.data!
.map((e) => Product.fromJson(e as Map<String, Object?>))
.toList();

Errors arrive as DioException, which carries the type of failure:

try {
await dio.post<void>('/orders', data: order.toJson());
} on DioException catch (e) {
final message = switch (e.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout => 'The server took too long.',
DioExceptionType.connectionError => 'No connection.',
DioExceptionType.cancel => 'Cancelled.',
DioExceptionType.badResponse =>
'Server returned ${e.response?.statusCode}.',
_ => 'Something went wrong.',
};
throw ApiException(message, statusCode: e.response?.statusCode);
}

Uploads and downloads:

final form = FormData.fromMap({
'file': await MultipartFile.fromFile(path, filename: 'photo.jpg'),
});

await dio.post<void>('/upload', data: form,
onSendProgress: (sent, total) => _progress.value = sent / total);

await dio.download(url, savePath,
onReceiveProgress: (received, total) => ...);

Interceptors

An interceptor is the right place for cross-cutting concerns — authentication, logging, retry — so individual call sites stay clean.

dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await tokenStore.read();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (error, handler) async {
if (error.response?.statusCode == 401 && !_refreshing) {
_refreshing = true;
try {
await tokenStore.refresh();
final retried = await dio.fetch<dynamic>(error.requestOptions);
return handler.resolve(retried);
} catch (_) {
await session.signOut();
} finally {
_refreshing = false;
}
}
handler.next(error);
},
),
);

Two things this sketch must get right in production: refreshing once for concurrent 401s (queue the waiters rather than firing N refreshes), and not retrying the refresh endpoint itself. Add LogInterceptor in debug builds only — it prints request bodies, which will include credentials.

Cancellation and timeouts

Cancel in-flight requests when the user leaves the screen or types a new search term:

CancelToken? _token;

Future<void> search(String query) async {
_token?.cancel('superseded');
final token = _token = CancelToken();

final response = await dio.get<List<dynamic>>(
'/search',
queryParameters: {'q': query},
cancelToken: token,
);
// ...
}

@override
void dispose() {
_token?.cancel('disposed');
super.dispose();
}

Set timeouts on the client, not just per request; a request with no timeout can hang until the OS gives up, and the user sees a spinner forever.

Manual JSON parsing

For one or two models, hand-written parsing is honest and dependency-free:

class Product {
const Product({required this.id, required this.name, required this.price});

final String id;
final String name;
final double price;

factory Product.fromJson(Map<String, Object?> json) => Product(
id: json['id']! as String,
name: json['name']! as String,
price: (json['price']! as num).toDouble(),
);

Map<String, Object?> toJson() => {'id': id, 'name': name, 'price': price};
}

Note (json['price'] as num).toDouble() — a JSON number that happens to be whole arrives as int, and casting it straight to double throws. This is the single most common JSON bug in Dart.

With strict-casts enabled (see Project structure), the analyser forces you to write these casts explicitly, which is what makes the error visible at compile time.

json_serializable

Generates the parsing code from annotations.

flutter pub add json_annotation
flutter pub add --dev build_runner json_serializable
import 'package:json_annotation/json_annotation.dart';

part 'product.g.dart';

@JsonSerializable()
class Product {
const Product({required this.id, required this.name, required this.price});

final String id;
final String name;

@JsonKey(name: 'unit_price', defaultValue: 0)
final double price;

factory Product.fromJson(Map<String, Object?> json) =>
_$ProductFromJson(json);

Map<String, Object?> toJson() => _$ProductToJson(this);
}
dart run build_runner build --delete-conflicting-outputs
dart run build_runner watch # during development

Commit the generated .g.dart files or generate them in CI — pick one and be consistent, because a missing generated file is a confusing build failure.

freezed

freezed adds immutability, copyWith, value equality, and sealed unions on top of the same generation step.

flutter pub add freezed_annotation
flutter pub add --dev build_runner freezed json_serializable
@freezed
class Product with _$Product {
const factory Product({
required String id,
required String name,
@JsonKey(name: 'unit_price') @Default(0) double price,
}) = _Product;

factory Product.fromJson(Map<String, Object?> json) =>
_$ProductFromJson(json);
}

That gives you product.copyWith(name: 'New'), structural == and hashCode, and a toString — the things you would otherwise hand-write and get subtly wrong. Union types are where it really pays:

@freezed
sealed class AuthState with _$AuthState {
const factory AuthState.unknown() = AuthUnknown;
const factory AuthState.signedOut() = AuthSignedOut;
const factory AuthState.signedIn(User user) = AuthSignedIn;
}

The generated union is sealed, so a switch over it is checked for exhaustiveness — see Dart essentials.

Modelling failure

Exceptions are fine for programming errors, but expected failures (offline, unauthorised, validation) read better as values the UI can pattern-match:

sealed class Result<T> {
const Result();
}

final class Ok<T> extends Result<T> {
const Ok(this.value);
final T value;
}

final class Err<T> extends Result<T> {
const Err(this.failure);
final Failure failure;
}
final result = await repository.loadProducts();

return switch (result) {
Ok(value: final products) => ProductList(products: products),
Err(failure: NetworkFailure()) => const OfflineNotice(),
Err(failure: final f) => ErrorView(message: f.message),
};

Whichever you choose, decide once per project. Mixing thrown exceptions and result types in the same layer produces code where nobody knows what can fail.

Repositories

Keep HTTP details out of the UI. The repository owns the client, maps DTOs to domain types, and decides the caching policy.

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

Future<Result<List<Product>>> loadProducts({CancelToken? cancelToken}) async {
try {
final response = await _dio.get<List<dynamic>>(
'/products',
cancelToken: cancelToken,
);
final products = response.data!
.map((e) => Product.fromJson(e as Map<String, Object?>))
.toList();
return Ok(products);
} on DioException catch (e) {
return Err(Failure.fromDio(e));
} on FormatException {
return const Err(Failure.malformedResponse());
}
}
}

Caching options, in increasing order of effort: HTTP caching headers honoured by the platform stack, an in-memory map with a TTL, or persisted responses — see Local storage.

Testing

Inject the client so tests can supply a fake. With http, MockClient returns canned responses:

final client = MockClient((request) async =>
http.Response('[{"id":"1","name":"Espresso","unit_price":9.5}]', 200));

With dio, register a DioAdapter from http_mock_adapter, or inject a fake Dio. Assert on the parsed domain objects, not on the raw JSON — that keeps the test honest when the wire format changes. See Testing.

Next: Local storage.