
Flutter Networking
- 664 installs
- 105 repo stars
- Updated May 1, 2026
- madteacher/mad-agents-skills
flutter-networking is a Claude Code skill that adds timeout-protected HTTP client code with JSON decoding to Flutter apps for developers who need a reusable ApiService wrapper around package:http.
About
flutter-networking is a mad-agents-skills recipe for building robust HTTP networking in Flutter using dart:async, dart:convert, and package:http. It provides an ApiService class with configurable baseUrl, injectable http.Client, default 15-second timeout, optional HeadersProvider for auth tokens, and generic JsonDecoder typing for typed JSON responses. Developers reach for flutter-networking when wiring mobile or agent-based Flutter apps to REST backends without reinventing timeout, header, and client lifecycle handling. The pattern supports owned vs injected clients for testability.
- Generic JSON GET/POST/PUT methods with automatic decoding
- Configurable 15-second request timeout
- Optional dynamic headers provider for auth tokens
- Automatic client lifecycle management with ownership flag
- Reusable ApiService class for Flutter networking layer
Flutter Networking by the numbers
- 664 all-time installs (skills.sh)
- Ranked #542 of 4,353 Backend & APIs skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 25, 2026 (Skillselion catalog sync)
npx skills add https://github.com/madteacher/mad-agents-skills --skill flutter-networkingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 664 |
|---|---|
| repo stars | ★ 105 |
| Security audit | 2 / 3 scanners passed |
| Last updated | May 1, 2026 |
| Repository | madteacher/mad-agents-skills ↗ |
How do you add timeout-safe HTTP networking in Flutter?
Add robust, timeout-protected HTTP client capabilities with JSON decoding to their Flutter mobile or agent-based applications.
Who is it for?
Flutter developers integrating REST APIs who want a production-minded http.Client wrapper with timeouts and typed JSON decoding.
Skip if: Projects already standardized on Dio, Chopper, or graphql_flutter with established networking layers.
When should I use this skill?
The user needs HTTP client setup, API service code, JSON decoding, or timeout handling in a Flutter mobile or agent app.
What you get
A reusable ApiService Dart class with base URL, timeout, headers provider, and JsonDecoder-typed response parsing.
- ApiService Dart class
- typed JSON decoder methods
By the numbers
- Default HTTP timeout of 15 seconds
Files
Flutter Networking
You are a networking agent for Flutter apps. Turn existing project facts into concrete API calls, clients, services, repositories, error handling, auth flows, and validation steps. Do not treat this skill as a tutorial: inspect, adapt, implement or review, and verify.
Core Contract
1. Confirm the target is a Flutter or Dart package by inspecting pubspec.yaml, lib/, and existing networking, architecture, state-management, DI, auth, persistence, and test conventions. 2. Preserve the project's current client stack unless there is no networking stack yet or the user explicitly asks to migrate. Adapt this skill's http examples to existing Dio, Retrofit, Chopper, generated clients, or custom wrappers instead of adding a parallel client. 3. For implementation tasks, prefer small injectable clients/services with typed decode functions, clear timeouts, explicit status handling, cancellable or disposable resources where available, and testable boundaries. 4. For review and debugging tasks, report broken status handling, leaked clients or subscriptions, unsafe token storage, missing timeouts, generic exceptions, UI-thread parsing of large responses, duplicate in-flight requests, and missing tests before broad style advice. 5. Keep UI networking thin. Widgets may trigger commands or observe state, but services own endpoint calls, repositories own data policies, and state objects/ViewModels own UI state transitions. 6. Validate with the repo's normal commands. Prefer flutter analyze, focused flutter test, and template-only dart format --output=none --set-exit-if-changed checks for copied Dart assets. Explain skipped checks.
Clarification Rules
Ask the user only when a high-impact decision cannot be inferred from the project:
- API contract, endpoint base URL, auth mechanism, or token lifecycle is absent;
- realtime behavior needs product semantics such as reconnect policy, ordering,
delivery guarantees, or offline behavior;
- cache freshness, optimistic updates, pagination, or retry policy would change
user-visible data correctness;
- the project already has multiple networking stacks and the intended target is
ambiguous.
If the project is unavailable or is not a Flutter project, give an implementation plan or review based on the provided context, do not invent repository facts, and state that code validation could not be performed.
Resource Routing
Read only the references and assets needed for the current task:
| Need | Read | Use for |
|---|---|---|
| Basic HTTP CRUD or JSON models | http-basics.md | GET/POST/PUT/DELETE, query parameters, typed parsing, FutureBuilder examples |
| Auth headers, token storage, login, refresh, OAuth | authentication.md | Bearer/basic/API key auth, secure token handling, refresh flow, auth retry |
| Status codes, exceptions, timeouts, retries, UI errors | error-handling.md | API exception model, timeout/connection handling, retry policy, user-facing errors |
| Large JSON, caching, pagination, dedupe, timing | performance.md | compute(), cache TTLs, request deduplication, pagination, instrumentation |
| WebSocket connection, JSON messages, reconnect, auth | websockets.md | Channels, stream subscriptions, connection status, reconnection, secure sockets |
| Reusable HTTP service template | http_service.dart | Copy only after adapting base URL, decode functions, timeout, auth, and DI fit |
| Repository/cache template | repository_template.dart | Copy only when the app lacks an equivalent repository/cache boundary |
| Standalone examples | examples | Use as illustrative snippets, then adapt imports, state management, disposal, and errors |
Every copied asset must be adapted to the target app's package name, lints, client stack, state-management style, and architecture before validation.
Networking Defaults
- Use
http: ^1.6.0andweb_socket_channel: ^3.0.3only for new simple
clients. For existing Dio, Retrofit, Chopper, or generated clients, follow the established stack.
- Inject clients instead of constructing them deep inside services. Close owned
http.Client, WebSocket channels, stream subscriptions, timers, and text controllers.
- Treat
200..299as success only when the endpoint contract allows it. Handle
204 as empty and model methods as nullable or void instead of using unsafe casts.
- Decode JSON into typed models at service/repository boundaries. Use
background isolates for large responses, but avoid isolate overhead for small payloads.
- Add request timeouts and retry only transient failures. Do not retry unsafe
mutations unless the API is idempotent or the user confirms the product policy.
- Store sensitive tokens with
flutter_secure_storage: ^10.0.0or the app's
existing secure storage. Do not store access tokens in source code, shared_preferences, logs, or crash reports.
- Use
wss://for WebSockets. Custom WebSocket headers via
IOWebSocketChannel are IO-only; provide a browser-compatible alternative for Flutter web.
- Do not manually set
Accept-Encodingas a default withpackage:http; let the
platform/client negotiate compression unless the project has a measured need.
Validation
Before finishing an implementation or review:
1. Check that endpoint calls are behind testable services or repositories and that UI code does not own raw HTTP/WebSocket details. 2. Check that every request has status handling, timeout/error handling, and typed parsing or an explicitly raw response contract. 3. Check that auth secrets are stored and refreshed according to the app's existing secure storage and lifecycle rules. 4. Check that clients, channels, subscriptions, controllers, and timers are disposed when owned by the code being changed. 5. Run the closest available validation:
flutter analyze- focused
flutter testsuites for changed network/auth/repository code dart format --output=none --set-exit-if-changedfor copied Dart assets- this skill's
scripts/verify-examples.shwhen changing bundled examples or
templates 6. Report commands run, failures, skipped checks, and residual networking risks.
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
typedef JsonDecoder<T> = T Function(Object? json);
typedef HeadersProvider = FutureOr<Map<String, String>> Function();
class ApiService {
final http.Client _client;
final String _baseUrl;
final Duration _timeout;
final HeadersProvider? _headersProvider;
final bool _ownsClient;
ApiService({
required String baseUrl,
http.Client? client,
Duration timeout = const Duration(seconds: 15),
HeadersProvider? headersProvider,
}) : _baseUrl = baseUrl,
_client = client ?? http.Client(),
_timeout = timeout,
_headersProvider = headersProvider,
_ownsClient = client == null;
Future<T?> getJson<T>(
String path, {
Map<String, String>? queryParameters,
Map<String, String>? headers,
required JsonDecoder<T> decode,
}) {
return _sendJson(
'GET',
path,
queryParameters: queryParameters,
headers: headers,
decode: decode,
);
}
Future<T?> postJson<T>(
String path, {
Object? body,
Map<String, String>? queryParameters,
Map<String, String>? headers,
required JsonDecoder<T> decode,
}) {
return _sendJson(
'POST',
path,
queryParameters: queryParameters,
headers: headers,
body: body,
decode: decode,
);
}
Future<T?> putJson<T>(
String path, {
Object? body,
Map<String, String>? queryParameters,
Map<String, String>? headers,
required JsonDecoder<T> decode,
}) {
return _sendJson(
'PUT',
path,
queryParameters: queryParameters,
headers: headers,
body: body,
decode: decode,
);
}
Future<T?> deleteJson<T>(
String path, {
Map<String, String>? queryParameters,
Map<String, String>? headers,
JsonDecoder<T>? decode,
}) {
return _sendJson(
'DELETE',
path,
queryParameters: queryParameters,
headers: headers,
decode: decode,
);
}
Future<T?> _sendJson<T>(
String method,
String path, {
Map<String, String>? queryParameters,
Map<String, String>? headers,
Object? body,
JsonDecoder<T>? decode,
}) async {
final uri = _uri(path, queryParameters);
final requestHeaders = await _headers(headers, hasBody: body != null);
final encodedBody = body == null ? null : jsonEncode(body);
try {
final response = await switch (method) {
'GET' => _client.get(uri, headers: requestHeaders),
'POST' => _client.post(uri, headers: requestHeaders, body: encodedBody),
'PUT' => _client.put(uri, headers: requestHeaders, body: encodedBody),
'DELETE' => _client.delete(uri, headers: requestHeaders),
_ => throw ArgumentError.value(method, 'method', 'Unsupported method'),
}.timeout(_timeout);
return _handleResponse(response, decode);
} on TimeoutException catch (error) {
throw ApiTimeoutException(
'Request timed out after ${_timeout.inSeconds}s',
cause: error,
);
} on http.ClientException catch (error) {
throw ApiNetworkException(error.message, cause: error);
}
}
Uri _uri(String path, Map<String, String>? queryParameters) {
final base = _baseUrl.endsWith('/')
? _baseUrl.substring(0, _baseUrl.length - 1)
: _baseUrl;
final normalizedPath = path.startsWith('/') ? path : '/$path';
return Uri.parse(
'$base$normalizedPath',
).replace(queryParameters: queryParameters);
}
Future<Map<String, String>> _headers(
Map<String, String>? extra, {
required bool hasBody,
}) async {
final provided = await _headersProvider?.call();
return {
'Accept': 'application/json',
if (hasBody && _extraBodyNeedsJson(extra))
'Content-Type': 'application/json',
...?provided,
...?extra,
};
}
bool _extraBodyNeedsJson(Map<String, String>? extra) {
return extra == null ||
!extra.keys.any((key) => key.toLowerCase() == 'content-type');
}
T? _handleResponse<T>(http.Response response, JsonDecoder<T>? decode) {
if (response.statusCode >= 200 && response.statusCode < 300) {
if (response.body.isEmpty) {
return null;
}
if (decode == null) {
throw ApiDecodeException('No decoder supplied for non-empty response');
}
return decode(jsonDecode(response.body));
}
throw ApiHttpException.fromResponse(response);
}
void dispose() {
if (_ownsClient) {
_client.close();
}
}
}
class ApiException implements Exception {
final String message;
final int? statusCode;
final String? responseBody;
final Object? cause;
const ApiException(
this.message, {
this.statusCode,
this.responseBody,
this.cause,
});
@override
String toString() {
final code = statusCode == null ? '' : ' ($statusCode)';
return '$runtimeType$code: $message';
}
}
class ApiHttpException extends ApiException {
const ApiHttpException(
super.message, {
required super.statusCode,
super.responseBody,
});
factory ApiHttpException.fromResponse(http.Response response) {
final message = response.reasonPhrase ?? 'HTTP ${response.statusCode}';
return switch (response.statusCode) {
400 => BadRequestException(message, response.body),
401 => UnauthorizedException(message, response.body),
403 => ForbiddenException(message, response.body),
404 => NotFoundException(message, response.body),
429 => TooManyRequestsException(message, response.body),
>= 500 && < 600 => ServerException(
message,
response.statusCode,
response.body,
),
_ => ApiHttpException(
message,
statusCode: response.statusCode,
responseBody: response.body,
),
};
}
}
class BadRequestException extends ApiHttpException {
const BadRequestException(super.message, String responseBody)
: super(statusCode: 400, responseBody: responseBody);
}
class UnauthorizedException extends ApiHttpException {
const UnauthorizedException(super.message, String responseBody)
: super(statusCode: 401, responseBody: responseBody);
}
class ForbiddenException extends ApiHttpException {
const ForbiddenException(super.message, String responseBody)
: super(statusCode: 403, responseBody: responseBody);
}
class NotFoundException extends ApiHttpException {
const NotFoundException(super.message, String responseBody)
: super(statusCode: 404, responseBody: responseBody);
}
class TooManyRequestsException extends ApiHttpException {
const TooManyRequestsException(super.message, String responseBody)
: super(statusCode: 429, responseBody: responseBody);
}
class ServerException extends ApiHttpException {
const ServerException(super.message, int statusCode, String responseBody)
: super(statusCode: statusCode, responseBody: responseBody);
}
class ApiNetworkException extends ApiException {
const ApiNetworkException(super.message, {super.cause});
}
class ApiTimeoutException extends ApiException {
const ApiTimeoutException(super.message, {super.cause});
}
class ApiDecodeException extends ApiException {
const ApiDecodeException(super.message, {super.cause});
}
import 'dart:async';
abstract class Repository<T> {
Future<T?> get(String id);
Future<List<T>> getAll();
Future<T> create(T entity);
Future<T> update(T entity);
Future<void> delete(String id);
}
class CacheRepository<T> implements Repository<T> {
final Map<String, T> _cache = {};
final Duration _cacheTtl;
final Map<String, DateTime> _cacheExpiry = {};
CacheRepository({Duration cacheTtl = const Duration(minutes: 5)})
: _cacheTtl = cacheTtl;
@override
Future<T?> get(String id) async {
if (!_cache.containsKey(id)) {
return null;
}
if (DateTime.now().isAfter(_cacheExpiry[id]!)) {
_cache.remove(id);
_cacheExpiry.remove(id);
return null;
}
return _cache[id];
}
@override
Future<List<T>> getAll() async {
return _cache.values.toList();
}
@override
Future<T> create(T entity) async {
final id = _getId(entity);
_cache[id] = entity;
_cacheExpiry[id] = DateTime.now().add(_cacheTtl);
return entity;
}
@override
Future<T> update(T entity) async {
final id = _getId(entity);
_cache[id] = entity;
_cacheExpiry[id] = DateTime.now().add(_cacheTtl);
return entity;
}
@override
Future<void> delete(String id) async {
_cache.remove(id);
_cacheExpiry.remove(id);
}
String _getId(T entity) {
if (entity is Identifiable) {
return (entity as Identifiable).id;
}
throw ArgumentError('Entity must implement Identifiable');
}
void clearCache() {
_cache.clear();
_cacheExpiry.clear();
}
}
abstract class Identifiable {
String get id;
}
class NetworkRepository<T> implements Repository<T> {
final Future<T?> Function(String id) _fetchFn;
final Future<List<T>> Function() _fetchAllFn;
final Future<T> Function(T entity) _createFn;
final Future<T> Function(T entity) _updateFn;
final Future<void> Function(String id) _deleteFn;
NetworkRepository({
required Future<T?> Function(String id) fetchFn,
required Future<List<T>> Function() fetchAllFn,
required Future<T> Function(T entity) createFn,
required Future<T> Function(T entity) updateFn,
required Future<void> Function(String id) deleteFn,
}) : _fetchFn = fetchFn,
_fetchAllFn = fetchAllFn,
_createFn = createFn,
_updateFn = updateFn,
_deleteFn = deleteFn;
@override
Future<T?> get(String id) async {
return await _fetchFn(id);
}
@override
Future<List<T>> getAll() async {
return await _fetchAllFn();
}
@override
Future<T> create(T entity) async {
return await _createFn(entity);
}
@override
Future<T> update(T entity) async {
return await _updateFn(entity);
}
@override
Future<void> delete(String id) async {
return await _deleteFn(id);
}
}
class HybridRepository<T> implements Repository<T> {
final CacheRepository<T> _cacheRepo;
final NetworkRepository<T> _networkRepo;
HybridRepository({
required Future<T?> Function(String id) fetchFn,
required Future<List<T>> Function() fetchAllFn,
required Future<T> Function(T entity) createFn,
required Future<T> Function(T entity) updateFn,
required Future<void> Function(String id) deleteFn,
Duration cacheTtl = const Duration(minutes: 5),
}) : _cacheRepo = CacheRepository(cacheTtl: cacheTtl),
_networkRepo = NetworkRepository(
fetchFn: fetchFn,
fetchAllFn: fetchAllFn,
createFn: createFn,
updateFn: updateFn,
deleteFn: deleteFn,
);
@override
Future<T?> get(String id) async {
final cached = await _cacheRepo.get(id);
if (cached != null) {
return cached;
}
final fetched = await _networkRepo.get(id);
if (fetched != null) {
await _cacheRepo.create(fetched);
}
return fetched;
}
@override
Future<List<T>> getAll() async {
final cached = await _cacheRepo.getAll();
if (cached.isNotEmpty) {
return cached;
}
final fetched = await _networkRepo.getAll();
for (final entity in fetched) {
await _cacheRepo.create(entity);
}
return fetched;
}
@override
Future<T> create(T entity) async {
final created = await _networkRepo.create(entity);
await _cacheRepo.create(created);
return created;
}
@override
Future<T> update(T entity) async {
final updated = await _networkRepo.update(entity);
await _cacheRepo.update(updated);
return updated;
}
@override
Future<void> delete(String id) async {
await _networkRepo.delete(id);
await _cacheRepo.delete(id);
}
void clearCache() {
_cacheRepo.clearCache();
}
}
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
class Album {
final int userId;
final int id;
final String title;
const Album({required this.userId, required this.id, required this.title});
factory Album.fromJson(Map<String, dynamic> json) {
return Album(
userId: json['userId'] as int,
id: json['id'] as int,
title: json['title'] as String,
);
}
}
Future<Album> fetchAlbum(String token, int id) async {
final response = await http.get(
Uri.parse('https://jsonplaceholder.typicode.com/albums/$id'),
headers: {'Authorization': 'Bearer $token'},
);
if (response.statusCode == 200) {
return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw Exception('Failed to load album');
}
}
class UnauthorizedException implements Exception {
@override
String toString() => 'Unauthorized: Please log in';
}
class AuthExample extends StatefulWidget {
const AuthExample({super.key});
@override
State<AuthExample> createState() => _AuthExampleState();
}
class _AuthExampleState extends State<AuthExample> {
final TextEditingController _tokenController = TextEditingController();
late Future<Album> futureAlbum;
@override
void initState() {
super.initState();
futureAlbum = Future<Album>.error('Please enter a token');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Authenticated Request Example')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _tokenController,
decoration: const InputDecoration(
labelText: 'Enter Bearer Token',
hintText: 'your_api_token_here',
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
setState(() {
futureAlbum = fetchAlbum(_tokenController.text, 1);
});
},
child: const Text('Fetch with Auth'),
),
const SizedBox(height: 24),
Expanded(
child: FutureBuilder<Album>(
future: futureAlbum,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(
child: Text(
'Error: ${snapshot.error}',
textAlign: TextAlign.center,
),
);
} else if (snapshot.hasData) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Album ID: ${snapshot.data!.id}'),
const SizedBox(height: 8),
Text('Title: ${snapshot.data!.title}'),
],
),
),
);
}
return const SizedBox();
},
),
),
],
),
),
);
}
@override
void dispose() {
_tokenController.dispose();
super.dispose();
}
}
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
class Photo {
final int albumId;
final int id;
final String title;
final String url;
final String thumbnailUrl;
const Photo({
required this.albumId,
required this.id,
required this.title,
required this.url,
required this.thumbnailUrl,
});
factory Photo.fromJson(Map<String, dynamic> json) {
return Photo(
albumId: json['albumId'] as int,
id: json['id'] as int,
title: json['title'] as String,
url: json['url'] as String,
thumbnailUrl: json['thumbnailUrl'] as String,
);
}
}
Future<List<Photo>> fetchPhotos(http.Client client) async {
final response = await client.get(
Uri.parse('https://jsonplaceholder.typicode.com/photos'),
);
if (response.statusCode == 200) {
return compute(parsePhotos, response.body);
} else {
throw Exception('Failed to load photos');
}
}
List<Photo> parsePhotos(String responseBody) {
final parsed = (jsonDecode(responseBody) as List)
.cast<Map<String, dynamic>>();
return parsed.map<Photo>((json) => Photo.fromJson(json)).toList();
}
class BackgroundParsingExample extends StatefulWidget {
const BackgroundParsingExample({super.key});
@override
State<BackgroundParsingExample> createState() =>
_BackgroundParsingExampleState();
}
class _BackgroundParsingExampleState extends State<BackgroundParsingExample> {
late final http.Client _client;
late Future<List<Photo>> futurePhotos;
@override
void initState() {
super.initState();
_client = http.Client();
futurePhotos = fetchPhotos(_client);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Background Parsing Example'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
setState(() {
futurePhotos = fetchPhotos(_client);
});
},
),
],
),
body: FutureBuilder<List<Photo>>(
future: futurePhotos,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text('Error: ${snapshot.error}'),
],
),
);
} else if (snapshot.hasData) {
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
final photo = snapshot.data![index];
return ListTile(
leading: Image.network(
photo.thumbnailUrl,
width: 50,
height: 50,
fit: BoxFit.cover,
),
title: Text('Photo ${photo.id}'),
subtitle: Text(photo.title),
);
},
);
}
return const SizedBox();
},
),
);
}
@override
void dispose() {
_client.close();
super.dispose();
}
}
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
class Album {
final int userId;
final int id;
final String title;
const Album({required this.userId, required this.id, required this.title});
factory Album.fromJson(Map<String, dynamic> json) {
return Album(
userId: json['userId'] as int,
id: json['id'] as int,
title: json['title'] as String,
);
}
}
Future<Album> fetchAlbum(int id) async {
final response = await http.get(
Uri.parse('https://jsonplaceholder.typicode.com/albums/$id'),
);
if (response.statusCode == 200) {
return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
} else {
throw Exception('Failed to load album');
}
}
class FetchExample extends StatefulWidget {
const FetchExample({super.key});
@override
State<FetchExample> createState() => _FetchExampleState();
}
class _FetchExampleState extends State<FetchExample> {
late Future<Album> futureAlbum;
@override
void initState() {
super.initState();
futureAlbum = fetchAlbum(1);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Fetch Data Example')),
body: Center(
child: FutureBuilder<Album>(
future: futureAlbum,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else if (snapshot.hasData) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Album ID: ${snapshot.data!.id}'),
const SizedBox(height: 8),
Text('Title: ${snapshot.data!.title}'),
],
);
}
return const Text('No data');
},
),
),
);
}
}
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
class Album {
final int id;
final String title;
const Album({required this.id, required this.title});
factory Album.fromJson(Map<String, dynamic> json) {
return Album(id: json['id'] as int, title: json['title'] as String);
}
}
Future<Album> createAlbum(String title) async {
final response = await http.post(
Uri.parse('https://jsonplaceholder.typicode.com/albums'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode(<String, String>{'title': title}),
);
if (response.statusCode == 201) {
return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
} else {
throw Exception('Failed to create album.');
}
}
class PostExample extends StatefulWidget {
const PostExample({super.key});
@override
State<PostExample> createState() => _PostExampleState();
}
class _PostExampleState extends State<PostExample> {
final TextEditingController _controller = TextEditingController();
Future<Album>? _futureAlbum;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Create Data Example')),
body: Container(
alignment: Alignment.center,
padding: const EdgeInsets.all(8),
child: (_futureAlbum == null) ? buildColumn() : buildFutureBuilder(),
),
);
}
Column buildColumn() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
TextField(
controller: _controller,
decoration: const InputDecoration(hintText: 'Enter Title'),
),
ElevatedButton(
onPressed: () {
setState(() {
_futureAlbum = createAlbum(_controller.text);
});
},
child: const Text('Create Data'),
),
],
);
}
FutureBuilder<Album> buildFutureBuilder() {
return FutureBuilder<Album>(
future: _futureAlbum,
builder: (context, snapshot) {
if (snapshot.hasData) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Created Album ID: ${snapshot.data!.id}'),
const SizedBox(height: 8),
Text('Title: ${snapshot.data!.title}'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
setState(() {
_futureAlbum = null;
_controller.clear();
});
},
child: const Text('Create Another'),
),
],
);
} else if (snapshot.hasError) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: ${snapshot.error}'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
setState(() {
_futureAlbum = null;
});
},
child: const Text('Retry'),
),
],
);
}
return const CircularProgressIndicator();
},
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
import 'package:flutter/material.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
class WebSocketExample extends StatefulWidget {
const WebSocketExample({super.key});
@override
State<WebSocketExample> createState() => _WebSocketExampleState();
}
class _WebSocketExampleState extends State<WebSocketExample> {
final TextEditingController _controller = TextEditingController();
final _channel = WebSocketChannel.connect(
Uri.parse('wss://echo.websocket.events'),
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('WebSocket Demo')),
body: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Form(
child: TextFormField(
controller: _controller,
decoration: const InputDecoration(labelText: 'Send a message'),
),
),
const SizedBox(height: 24),
const Text(
'Server Response:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Expanded(
child: StreamBuilder(
stream: _channel.stream,
builder: (context, snapshot) {
if (snapshot.hasData) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
child: Text(
snapshot.data as String,
style: const TextStyle(fontFamily: 'monospace'),
),
);
}
return const Text('Waiting for messages...');
},
),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _sendMessage,
tooltip: 'Send message',
child: const Icon(Icons.send),
),
);
}
void _sendMessage() {
if (_controller.text.isNotEmpty) {
_channel.sink.add(_controller.text);
_controller.clear();
}
}
@override
void dispose() {
_channel.sink.close();
_controller.dispose();
super.dispose();
}
}
Authentication
Use this reference when a Flutter networking task needs authorization headers, token storage, login, refresh, or authenticated retry behavior.
Authentication Methods
Bearer Token
Future<Album> fetchAlbum(http.Client client, String token) async {
final response = await client.get(
Uri.parse('https://api.example.com/albums/1'),
headers: {'Authorization': 'Bearer $token'},
);
if (response.statusCode == 200) {
return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
throw ApiHttpException.fromResponse(response);
}Basic Authentication
String basicAuthHeader(String username, String password) {
final credentials = '$username:$password';
return 'Basic ${base64Encode(utf8.encode(credentials))}';
}
final response = await client.get(
Uri.parse('https://api.example.com/me'),
headers: {'Authorization': basicAuthHeader(username, password)},
);API Key
final response = await client.get(
Uri.parse('https://api.example.com/data'),
headers: {'X-API-Key': apiKey},
);Token Storage
Use flutter_secure_storage for access tokens, refresh tokens, and other sensitive values:
dependencies:
flutter_secure_storage: ^10.0.0import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class TokenPair {
final String accessToken;
final String refreshToken;
final DateTime expiresAt;
const TokenPair({
required this.accessToken,
required this.refreshToken,
required this.expiresAt,
});
bool get isExpired {
final refreshWindow = DateTime.now().add(const Duration(minutes: 1));
return !expiresAt.isAfter(refreshWindow);
}
}
abstract class TokenStorage {
Future<TokenPair?> read();
Future<void> save(TokenPair tokens);
Future<void> clear();
}
class SecureTokenStorage implements TokenStorage {
static const _accessTokenKey = 'access_token';
static const _refreshTokenKey = 'refresh_token';
static const _expiresAtKey = 'expires_at';
final FlutterSecureStorage _storage;
const SecureTokenStorage([
this._storage = const FlutterSecureStorage(),
]);
@override
Future<TokenPair?> read() async {
final accessToken = await _storage.read(key: _accessTokenKey);
final refreshToken = await _storage.read(key: _refreshTokenKey);
final expiresAtRaw = await _storage.read(key: _expiresAtKey);
if (accessToken == null || refreshToken == null || expiresAtRaw == null) {
return null;
}
final expiresAt = DateTime.tryParse(expiresAtRaw);
if (expiresAt == null) {
await clear();
return null;
}
return TokenPair(
accessToken: accessToken,
refreshToken: refreshToken,
expiresAt: expiresAt,
);
}
@override
Future<void> save(TokenPair tokens) async {
await _storage.write(key: _accessTokenKey, value: tokens.accessToken);
await _storage.write(key: _refreshTokenKey, value: tokens.refreshToken);
await _storage.write(
key: _expiresAtKey,
value: tokens.expiresAt.toIso8601String(),
);
}
@override
Future<void> clear() async {
await _storage.delete(key: _accessTokenKey);
await _storage.delete(key: _refreshTokenKey);
await _storage.delete(key: _expiresAtKey);
}
}shared_preferences is acceptable for non-sensitive session flags or cached profile preferences. If it is needed, use the current package line:
dependencies:
shared_preferences: ^2.5.5Do not store bearer tokens, refresh tokens, passwords, or API keys in shared_preferences.
Token Refresh
Keep refresh logic in an auth manager or authenticated client. The manager owns the token lifecycle; callers ask it for a valid access token.
class AuthManager {
final TokenStorage _tokenStorage;
final http.Client _client;
AuthManager(this._tokenStorage, this._client);
Future<String> getAccessToken() async {
final tokens = await _tokenStorage.read();
if (tokens == null) {
throw const UnauthorizedException('Missing auth tokens');
}
if (!tokens.isExpired) {
return tokens.accessToken;
}
return refreshAccessToken(tokens.refreshToken);
}
Future<String> refreshAccessToken(String refreshToken) async {
final response = await _client.post(
Uri.parse('https://api.example.com/auth/refresh'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'refreshToken': refreshToken}),
);
if (response.statusCode != 200) {
await _tokenStorage.clear();
throw ApiHttpException.fromResponse(response);
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final tokens = TokenPair(
accessToken: data['accessToken'] as String,
refreshToken: data['refreshToken'] as String,
expiresAt: DateTime.parse(data['expiresAt'] as String),
);
await _tokenStorage.save(tokens);
return tokens.accessToken;
}
Future<void> logout() {
return _tokenStorage.clear();
}
Future<TokenPair?> readTokens() {
return _tokenStorage.read();
}
}Authenticated Client
Do not resend the same BaseRequest after a 401. A streamed request may already be finalized. Accept a request factory, recreate the request after refresh, and retry only when the original operation is safe for the product/API contract.
typedef RequestFactory = http.BaseRequest Function(String accessToken);
class AuthenticatedClient {
final http.Client _inner;
final AuthManager _authManager;
AuthenticatedClient(this._inner, this._authManager);
Future<http.StreamedResponse> send(
RequestFactory requestFactory, {
bool retryOnUnauthorized = true,
}) async {
final token = await _authManager.getAccessToken();
final response = await _inner.send(requestFactory(token));
if (response.statusCode != 401 || !retryOnUnauthorized) {
return response;
}
final tokens = await _authManager.readTokens();
if (tokens == null) {
return response;
}
await response.stream.drain<void>();
final newToken = await _authManager.refreshAccessToken(tokens.refreshToken);
return _inner.send(requestFactory(newToken));
}
}Usage:
final response = await authenticatedClient.send(
(token) => http.Request(
'GET',
Uri.parse('https://api.example.com/albums/1'),
)..headers['Authorization'] = 'Bearer $token',
);Login Flow
class AuthService {
final http.Client _client;
final TokenStorage _tokenStorage;
AuthService(this._client, this._tokenStorage);
Future<User> login(String email, String password) async {
final response = await _client.post(
Uri.parse('https://api.example.com/auth/login'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email, 'password': password}),
);
if (response.statusCode == 401) {
throw const UnauthorizedException('Invalid email or password');
}
if (response.statusCode != 200) {
throw ApiHttpException.fromResponse(response);
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
await _tokenStorage.save(
TokenPair(
accessToken: data['accessToken'] as String,
refreshToken: data['refreshToken'] as String,
expiresAt: DateTime.parse(data['expiresAt'] as String),
),
);
return User.fromJson(data['user'] as Map<String, dynamic>);
}
}OAuth2
Use an OAuth2 package or the platform's browser flow for production OAuth. If a project already has an OAuth helper, adapt to that helper instead of adding a second flow. Never embed client secrets in a mobile app.
Best Practices
1. Store sensitive tokens in secure storage, not shared_preferences. 2. Refresh tokens before expiration and clear storage when refresh fails. 3. Retry 401s with a newly created request, not an already-sent request object. 4. Send tokens only over HTTPS or wss://. 5. Keep token values out of logs, analytics, crash reports, screenshots, and source control. 6. Scope tokens narrowly and follow the backend's rotation policy.
Error Handling
Overview
Complete guide for handling errors in Flutter networking operations.
HTTP Status Code Handling
Standard Status Codes
Future<Album> fetchAlbum(int id) async {
final response = await http.get(
Uri.parse('https://api.example.com/albums/$id'),
);
switch (response.statusCode) {
case 200:
case 201:
return Album.fromJson(jsonDecode(response.body));
case 204:
throw JsonParseException('Expected album body, got empty response');
case 400:
throw BadRequestException('Invalid request');
case 401:
throw UnauthorizedException('Not authenticated');
case 403:
throw ForbiddenException('Access denied');
case 404:
throw NotFoundException('Album not found');
case 429:
throw TooManyRequestsException('Rate limit exceeded');
case 500:
case 502:
case 503:
throw ServerException('Server error');
default:
throw HttpException(
'HTTP ${response.statusCode}: ${response.reasonPhrase}',
);
}
}Custom Exception Classes
Base Exception Classes
abstract class ApiException implements Exception {
final String message;
final int? statusCode;
ApiException(this.message, [this.statusCode]);
@override
String toString() => message;
}
class BadRequestException extends ApiException {
BadRequestException(String message) : super(message, 400);
}
class UnauthorizedException extends ApiException {
UnauthorizedException(String message) : super(message, 401);
}
class ForbiddenException extends ApiException {
ForbiddenException(String message) : super(message, 403);
}
class NotFoundException extends ApiException {
NotFoundException(String message) : super(message, 404);
}
class TooManyRequestsException extends ApiException {
TooManyRequestsException(String message) : super(message, 429);
}
class ServerException extends ApiException {
ServerException(String message) : super(message, 500);
}
class NetworkException extends ApiException {
NetworkException(String message) : super(message);
}
class ApiTimeoutException extends ApiException {
ApiTimeoutException(String message) : super(message);
}Parsing Errors
JSON Parsing with Error Handling
class Album {
final int userId;
final int id;
final String title;
Album({required this.userId, required this.id, required this.title});
factory Album.fromJson(Map<String, dynamic> json) {
try {
return Album(
userId: json['userId'] as int,
id: json['id'] as int,
title: json['title'] as String,
);
} on TypeError catch (e) {
throw JsonParseException('Failed to parse album: $e');
} catch (e) {
throw JsonParseException('Unknown parsing error: $e');
}
}
}
class JsonParseException extends ApiException {
JsonParseException(String message) : super(message);
}Safe JSON Parsing
class SafeParser {
static String? parseString(Map<String, dynamic> json, String key) {
try {
return json[key] as String?;
} catch (_) {
return null;
}
}
static int? parseInt(Map<String, dynamic> json, String key) {
try {
return json[key] as int?;
} catch (_) {
return null;
}
}
static double? parseDouble(Map<String, dynamic> json, String key) {
try {
return json[key] as double?;
} catch (_) {
return null;
}
}
}
// Usage
factory User.fromJson(Map<String, dynamic> json) {
return User(
name: SafeParser.parseString(json, 'name') ?? 'Unknown',
age: SafeParser.parseInt(json, 'age') ?? 0,
score: SafeParser.parseDouble(json, 'score') ?? 0.0,
);
}Network Error Handling
Timeout Handling
import 'dart:async';
Future<T> withTimeout<T>(Future<T> future, Duration duration) async {
try {
return await future.timeout(
duration,
onTimeout: () {
throw ApiTimeoutException(
'Request timed out after ${duration.inSeconds}s',
);
},
);
} on TimeoutException catch (e) {
throw ApiTimeoutException(e.message ?? 'Request timed out');
}
}
// Usage
final response = await withTimeout(
http.get(Uri.parse('https://api.example.com/data')),
const Duration(seconds: 10),
);Connection Errors
Future<T> withConnectionHandling<T>(Future<T> future) async {
try {
return await future;
} on SocketException catch (e) {
throw NetworkException('No internet connection: ${e.message}');
} on HttpException catch (e) {
throw NetworkException('HTTP error: ${e.message}');
} on FormatException catch (e) {
throw NetworkException('Invalid response format: ${e.message}');
} catch (e) {
throw NetworkException('Unknown network error: $e');
}
}Error Display in UI
Error Widget
class ErrorDisplay extends StatelessWidget {
final Object error;
final VoidCallback? onRetry;
const ErrorDisplay({
super.key,
required this.error,
this.onRetry,
});
String get _errorMessage {
if (error is NetworkException) {
return 'Network error. Please check your connection.';
} else if (error is UnauthorizedException) {
return 'You are not authorized. Please log in.';
} else if (error is NotFoundException) {
return 'The requested resource was not found.';
} else if (error is ServerException) {
return 'Server error. Please try again later.';
} else if (error is ApiTimeoutException) {
return 'Request timed out. Please try again.';
} else {
return 'An error occurred: $error';
}
}
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text(
_errorMessage,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
if (onRetry != null) ...[
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
],
),
),
);
}
}FutureBuilder with Error Handling
FutureBuilder<Album>(
future: futureAlbum,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return ErrorDisplay(
error: snapshot.error!,
onRetry: () {
setState(() {
futureAlbum = fetchAlbum();
});
},
);
}
if (snapshot.hasData) {
return AlbumCard(album: snapshot.data!);
}
return const SizedBox();
},
)Retry Logic
Exponential Backoff
Future<T> fetchWithRetry<T>(
Future<T> Function() fetch, {
int maxRetries = 3,
Duration initialDelay = const Duration(seconds: 1),
}) async {
for (int i = 0; i < maxRetries; i++) {
try {
return await fetch();
} catch (e) {
if (i == maxRetries - 1) rethrow;
if (!_isRetryableError(e)) rethrow;
final delay = initialDelay * (i + 1);
await Future.delayed(delay);
}
}
throw StateError('Unreachable');
}
bool _isRetryableError(Object error) {
return error is NetworkException ||
error is ApiTimeoutException ||
error is ServerException ||
error is TooManyRequestsException;
}Retry on Specific Status Codes
Future<Album> fetchAlbumWithRetry(int id) async {
return await fetchWithRetry(
() => fetchAlbum(id),
maxRetries: 3,
);
}Error Logging
Error Logger
class ErrorLogger {
static void logError(Object error, StackTrace? stackTrace) {
debugPrint('Error: $error');
if (stackTrace != null) {
debugPrint('StackTrace: $stackTrace');
}
// Send to error tracking service
// AnalyticsService.logError(error, stackTrace);
}
}
// Usage
try {
await fetchAlbum();
} catch (e, stackTrace) {
ErrorLogger.logError(e, stackTrace);
}Best Practices
1. Handle all status codes - Don't assume 200 is the only success code 2. Use specific exception types - Create custom exceptions for different error types 3. Parse JSON safely - Handle type errors gracefully 4. Implement retry logic - Retry transient failures with exponential backoff 5. Set timeouts - Prevent indefinite hanging requests 6. Display user-friendly messages - Show clear error messages in the UI 7. Log errors - Track errors for debugging and monitoring 8. Provide retry options - Allow users to retry failed operations
HTTP Basics
Overview
Complete guide for HTTP operations in Flutter using the http package.
Dependencies
Add to pubspec.yaml:
dependencies:
http: ^1.6.0GET Requests
Basic GET Request
import 'package:http/http.dart' as http;
import 'dart:convert';
Future<Album> fetchAlbum(int id) async {
final response = await http.get(
Uri.parse('https://jsonplaceholder.typicode.com/albums/$id'),
);
if (response.statusCode == 200) {
return Album.fromJson(jsonDecode(response.body));
} else {
throw Exception('Failed to load album');
}
}GET with Query Parameters
final response = await http.get(
Uri.parse('https://api.example.com/albums')
.replace(queryParameters: {
'userId': '1',
'_limit': '10',
}),
);GET with Custom Headers
final response = await http.get(
Uri.parse('https://api.example.com/data'),
headers: {
'Accept': 'application/json',
'User-Agent': 'MyApp/1.0',
},
);POST Requests
Create Resource
Future<Album> createAlbum(String title) async {
final response = await http.post(
Uri.parse('https://jsonplaceholder.typicode.com/albums'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode(<String, String>{'title': title}),
);
if (response.statusCode == 201) {
return Album.fromJson(jsonDecode(response.body));
} else {
throw Exception('Failed to create album');
}
}POST with Complex Object
Future<User> createUser(User user) async {
final response = await http.post(
Uri.parse('https://api.example.com/users'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode(user.toJson()),
);
if (response.statusCode == 201) {
return User.fromJson(jsonDecode(response.body));
} else {
throw Exception('Failed to create user');
}
}PUT Requests
Update Entire Resource
Future<Album> updateAlbum(int id, String title) async {
final response = await http.put(
Uri.parse('https://jsonplaceholder.typicode.com/albums/$id'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode(<String, String>{'title': title, 'id': id.toString()}),
);
if (response.statusCode == 200) {
return Album.fromJson(jsonDecode(response.body));
} else {
throw Exception('Failed to update album');
}
}DELETE Requests
Delete Resource
Future<Album> deleteAlbum(String id) async {
final http.Response response = await http.delete(
Uri.parse('https://jsonplaceholder.typicode.com/albums/$id'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
);
if (response.statusCode == 200) {
return Album.fromJson(jsonDecode(response.body));
} else {
throw Exception('Failed to delete album');
}
}Model Classes
JSON Parsing Pattern
class Album {
final int userId;
final int id;
final String title;
const Album({
required this.userId,
required this.id,
required this.title,
});
factory Album.fromJson(Map<String, dynamic> json) {
return switch (json) {
{'userId': int userId, 'id': int id, 'title': String title} => Album(
userId: userId,
id: id,
title: title,
),
_ => throw const FormatException('Failed to load album.'),
};
}
Map<String, dynamic> toJson() => {
'userId': userId,
'id': id,
'title': title,
};
}Nested JSON Parsing
class User {
final int id;
final String name;
final Address address;
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as int,
name: json['name'] as String,
address: Address.fromJson(json['address'] as Map<String, dynamic>),
);
}
}
class Address {
final String street;
final String city;
factory Address.fromJson(Map<String, dynamic> json) {
return Address(
street: json['street'] as String,
city: json['city'] as String,
);
}
}List Parsing
Future<List<Album>> fetchAlbums() async {
final response = await http.get(
Uri.parse('https://jsonplaceholder.typicode.com/albums'),
);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body);
return data.map((json) => Album.fromJson(json)).toList();
} else {
throw Exception('Failed to load albums');
}
}Using with FutureBuilder
class _MyAppState extends State<MyApp> {
late Future<Album> futureAlbum;
@override
void initState() {
super.initState();
futureAlbum = fetchAlbum(1);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Album>(
future: futureAlbum,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else if (snapshot.hasData) {
return Text(snapshot.data!.title);
} else {
return const Text('No data');
}
},
);
}
}Timeout Configuration
import 'dart:async';
Future<Album> fetchAlbum() async {
try {
final response = await http.get(
Uri.parse('https://api.example.com/albums/1'),
).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('Request timeout');
},
);
if (response.statusCode == 200) {
return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
throw ApiHttpException.fromResponse(response);
} on TimeoutException catch (e) {
throw ApiTimeoutException('Request timed out: ${e.message}');
}
}Common Response Headers
final contentType = response.headers['content-type'];
final contentLength = int.parse(response.headers['content-length'] ?? '0');
final cacheControl = response.headers['cache-control'];Best Practices
1. Always handle status codes - Check response.statusCode before processing 2. Use type-safe models - Create classes with fromJson/toJson methods 3. Set timeouts - Prevent indefinite hanging requests 4. Handle exceptions - Catch and properly handle network errors 5. Use appropriate HTTP methods - GET for reading, POST for creating, PUT for updating, DELETE for removing
Performance
Overview
Complete guide for optimizing Flutter networking performance.
Background Parsing with Isolates
Using compute() for JSON Parsing
For large JSON responses, parse in a background isolate to prevent UI jank:
dependencies:
http: ^1.6.0import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
Future<List<Photo>> fetchPhotos(http.Client client) async {
final response = await client.get(
Uri.parse('https://jsonplaceholder.typicode.com/photos'),
);
if (response.statusCode == 200) {
return compute(parsePhotos, response.body);
} else {
throw Exception('Failed to load photos');
}
}
List<Photo> parsePhotos(String responseBody) {
final parsed = (jsonDecode(responseBody) as List)
.cast<Map<String, dynamic>>();
return parsed.map<Photo>((json) => Photo.fromJson(json)).toList();
}When to Use compute()
Use compute() when:
- JSON response is larger than 10KB
- Parsing takes more than 10ms
- UI jank is noticeable during parsing
Don't use for:
- Small responses (<1KB)
- Simple objects with few fields
- When response speed is critical (isolate overhead)
Caching
In-Memory Cache
class CacheService<T> {
final Map<String, CacheEntry<T>> _cache = {};
final Duration defaultTtl;
CacheService({this.defaultTtl = const Duration(minutes: 5)});
Future<T?> get(String key) async {
final entry = _cache[key];
if (entry == null) return null;
if (DateTime.now().isAfter(entry.expiry)) {
_cache.remove(key);
return null;
}
return entry.value;
}
Future<void> set(String key, T value, {Duration? ttl}) async {
_cache[key] = CacheEntry(
value: value,
expiry: DateTime.now().add(ttl ?? defaultTtl),
);
}
Future<void> clear() async {
_cache.clear();
}
}
class CacheEntry<T> {
final T value;
final DateTime expiry;
CacheEntry({required this.value, required this.expiry});
}HTTP Cache Headers
Respect server cache directives:
Future<Album> fetchAlbum(int id) async {
final response = await http.get(
Uri.parse('https://api.example.com/albums/$id'),
);
final cacheControl = response.headers['cache-control'];
if (cacheControl != null) {
// Parse and respect cache headers
}
return Album.fromJson(jsonDecode(response.body));
}Request Batching
Batching Multiple Requests
Future<List<Album>> fetchAlbumsBatch(List<int> ids) async {
final futures = ids.map((id) => fetchAlbum(id));
return await Future.wait(futures);
}
// Usage
final albums = await fetchAlbumsBatch([1, 2, 3, 4, 5]);Request Deduplication
class RequestDeduplicator<T> {
final Map<String, Future<T>> _pendingRequests = {};
Future<T> fetch(String key, Future<T> Function() fetchFn) async {
if (_pendingRequests.containsKey(key)) {
return await _pendingRequests[key]!;
}
final future = fetchFn();
_pendingRequests[key] = future;
try {
return await future;
} finally {
_pendingRequests.remove(key);
}
}
}
// Usage
final deduplicator = RequestDeduplicator<Album>();
final album1 = await deduplicator.fetch(
'album-1',
() => fetchAlbum(1),
);
final album2 = await deduplicator.fetch(
'album-1', // Same key - won't make duplicate request
() => fetchAlbum(1),
);Pagination
Offset-Based Pagination
Future<List<Album>> fetchAlbumsPage({
int offset = 0,
int limit = 20,
}) async {
final response = await http.get(
Uri.parse('https://api.example.com/albums')
.replace(queryParameters: {
'offset': offset.toString(),
'limit': limit.toString(),
}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as List;
return data.map((json) => Album.fromJson(json)).toList();
} else {
throw Exception('Failed to load albums');
}
}Cursor-Based Pagination
Future<PageResult<Album>> fetchAlbumsPage({
String? cursor,
int limit = 20,
}) async {
final uri = Uri.parse('https://api.example.com/albums')
.replace(queryParameters: {
'limit': limit.toString(),
if (cursor != null) 'cursor': cursor,
});
final response = await http.get(uri);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return PageResult(
items: (data['items'] as List)
.map((json) => Album.fromJson(json))
.toList(),
nextCursor: data['nextCursor'] as String?,
);
} else {
throw Exception('Failed to load albums');
}
}
class PageResult<T> {
final List<T> items;
final String? nextCursor;
PageResult({required this.items, required this.nextCursor});
}Compression
package:http and the underlying platform clients normally negotiate compression for you. Do not set Accept-Encoding by default unless the app has a measured interoperability need and the chosen client stack supports the header on the target platforms.
Connection Pooling
Reuse HTTP Client
Create a single HTTP client instance for the app:
class SharedApiClient {
static final SharedApiClient _instance = SharedApiClient._internal();
factory SharedApiClient() => _instance;
final http.Client _client = http.Client();
SharedApiClient._internal();
http.Client get client => _client;
void dispose() {
_client.close();
}
}
// Usage
final apiClient = SharedApiClient();
final response = await apiClient.client.get(url);Optimistic UI
Optimistic Updates
class AlbumRepository {
final CacheService<Album> _cache;
Future<Album> updateAlbum(int id, String title) async {
// Optimistic update
final cachedAlbum = await _cache.get('album-$id');
final updatedAlbum = Album(
userId: cachedAlbum!.userId,
id: cachedAlbum.id,
title: title,
);
await _cache.set('album-$id', updatedAlbum);
try {
final networkAlbum = await _updateAlbumOnServer(id, title);
await _cache.set('album-$id', networkAlbum);
return networkAlbum;
} catch (e) {
// Revert on error
await _cache.set('album-$id', cachedAlbum);
rethrow;
}
}
Future<Album> _updateAlbumOnServer(int id, String title) async {
final response = await http.put(
Uri.parse('https://api.example.com/albums/$id'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'title': title}),
);
if (response.statusCode == 200) {
return Album.fromJson(jsonDecode(response.body));
} else {
throw Exception('Failed to update album');
}
}
}Lazy Loading
Lazy Load Images
class NetworkImageLoader {
static const int _maxCacheSize = 100;
static final Map<String, Uint8List> _imageCache = {};
static Future<Uint8List> loadImage(String url) async {
if (_imageCache.containsKey(url)) {
return _imageCache[url]!;
}
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) {
throw ApiHttpException.fromResponse(response);
}
final bytes = response.bodyBytes;
if (_imageCache.length >= _maxCacheSize) {
_imageCache.clear();
}
_imageCache[url] = bytes;
return bytes;
}
}Performance Monitoring
Request Timing
class RequestTimer {
Future<T> timeRequest<T>(String name, Future<T> Function() request) async {
final stopwatch = Stopwatch()..start();
try {
return await request();
} finally {
stopwatch.stop();
debugPrint('$name took ${stopwatch.elapsedMilliseconds}ms');
}
}
}
// Usage
final timer = RequestTimer();
final album = await timer.timeRequest('fetchAlbum', () => fetchAlbum(1));Best Practices
1. Use compute() for large JSON - Parse large responses in isolates 2. Implement caching - Reduce network requests with in-memory cache 3. Batch requests - Combine multiple requests when possible 4. Deduplicate requests - Avoid duplicate in-flight requests 5. Use pagination - Load data in chunks for large datasets 6. Enable compression - Use gzip for request/response compression 7. Reuse HTTP client - Create single client instance for app 8. Optimistic UI - Update UI immediately, rollback on error 9. Lazy load resources - Load images and resources on demand 10. Monitor performance - Track request times for optimization
WebSockets
Overview
Complete guide for implementing WebSocket connections in Flutter using the web_socket_channel package.
Dependencies
Add to pubspec.yaml:
dependencies:
web_socket_channel: ^3.0.3Basic Connection
Connecting to WebSocket
import 'package:web_socket_channel/web_socket_channel.dart';
final _channel = WebSocketChannel.connect(
Uri.parse('wss://echo.websocket.events'),
);Sending Messages
void _sendMessage(String message) {
if (message.isNotEmpty) {
_channel.sink.add(message);
}
}Receiving Messages with StreamBuilder
StreamBuilder(
stream: _channel.stream,
builder: (context, snapshot) {
return Text(snapshot.hasData ? '${snapshot.data}' : '');
},
)Closing Connection
@override
void dispose() {
_channel.sink.close();
super.dispose();
}Complete Example
import 'package:flutter/material.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
class WebSocketDemo extends StatefulWidget {
const WebSocketDemo({super.key});
@override
State<WebSocketDemo> createState() => _WebSocketDemoState();
}
class _WebSocketDemoState extends State<WebSocketDemo> {
final TextEditingController _controller = TextEditingController();
final _channel = WebSocketChannel.connect(
Uri.parse('wss://echo.websocket.events'),
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('WebSocket Demo')),
body: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Form(
child: TextFormField(
controller: _controller,
decoration: const InputDecoration(labelText: 'Send a message'),
),
),
const SizedBox(height: 24),
StreamBuilder(
stream: _channel.stream,
builder: (context, snapshot) {
return Text(snapshot.hasData ? '${snapshot.data}' : '');
},
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _sendMessage(_controller.text),
tooltip: 'Send message',
child: const Icon(Icons.send),
),
);
}
void _sendMessage(String message) {
if (message.isNotEmpty) {
_channel.sink.add(message);
}
}
@override
void dispose() {
_channel.sink.close();
_controller.dispose();
super.dispose();
}
}Connection States
Handling Connection States
import 'dart:async';
enum SocketConnectionStatus { connecting, connected, disconnected, error }
class _WebSocketDemoState extends State<WebSocketDemo> {
WebSocketChannel? _channel;
StreamSubscription<dynamic>? _subscription;
SocketConnectionStatus _connectionStatus = SocketConnectionStatus.connecting;
String _errorMessage = '';
@override
void initState() {
super.initState();
_connect();
}
void _connect() {
try {
final channel = WebSocketChannel.connect(Uri.parse('wss://example.com/ws'));
_subscription = channel.stream.listen(
(data) {
if (!mounted) return;
setState(() {
_connectionStatus = SocketConnectionStatus.connected;
});
},
onError: (error) {
if (!mounted) return;
setState(() {
_connectionStatus = SocketConnectionStatus.error;
_errorMessage = error.toString();
});
},
onDone: () {
if (!mounted) return;
setState(() {
_connectionStatus = SocketConnectionStatus.disconnected;
});
},
);
_channel = channel;
} catch (e) {
if (!mounted) return;
setState(() {
_connectionStatus = SocketConnectionStatus.error;
_errorMessage = e.toString();
});
}
}
@override
void dispose() {
_subscription?.cancel();
_channel?.sink.close();
super.dispose();
}
}Reconnection Logic
Automatic Reconnection
class _WebSocketDemoState extends State<WebSocketDemo> {
WebSocketChannel? _channel;
StreamSubscription<dynamic>? _subscription;
Timer? _reconnectTimer;
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 5;
void _connect() {
try {
_channel = WebSocketChannel.connect(Uri.parse('wss://example.com/ws'));
_subscription?.cancel();
_subscription = _channel!.stream.listen(
(data) {
_reconnectAttempts = 0;
},
onError: (error) {
_scheduleReconnect();
},
onDone: () {
_scheduleReconnect();
},
);
} catch (e) {
_scheduleReconnect();
}
}
void _scheduleReconnect() {
if (!mounted) return;
if (_reconnectAttempts >= _maxReconnectAttempts) {
return;
}
_reconnectAttempts++;
final delay = Duration(seconds: _reconnectAttempts * 2);
_reconnectTimer?.cancel();
_reconnectTimer = Timer(delay, () {
if (!mounted) return;
_connect();
});
}
@override
void dispose() {
_reconnectTimer?.cancel();
_subscription?.cancel();
_channel?.sink.close();
super.dispose();
}
}JSON Messages
Sending JSON Data
import 'dart:convert';
void _sendJsonMessage(Map<String, dynamic> data) {
_channel.sink.add(jsonEncode(data));
}
// Usage
_sendJsonMessage({
'type': 'chat',
'message': 'Hello, world!',
'userId': 123,
});Receiving JSON Data
StreamBuilder(
stream: _channel.stream,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const CircularProgressIndicator();
}
try {
final data = jsonDecode(snapshot.data as String) as Map<String, dynamic>;
return Text('Message: ${data['message']}');
} catch (e) {
return Text('Error parsing JSON: $e');
}
},
)Type-Safe Message Handling
abstract class WebSocketMessage {
factory WebSocketMessage.fromJson(Map<String, dynamic> json) {
final type = json['type'] as String;
switch (type) {
case 'chat':
return ChatMessage.fromJson(json);
case 'notification':
return NotificationMessage.fromJson(json);
default:
throw FormatException('Unknown message type: $type');
}
}
}
class ChatMessage extends WebSocketMessage {
final String message;
final int userId;
ChatMessage({required this.message, required this.userId});
factory ChatMessage.fromJson(Map<String, dynamic> json) {
return ChatMessage(
message: json['message'] as String,
userId: json['userId'] as int,
);
}
}
StreamBuilder(
stream: _channel.stream,
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox();
try {
final message = WebSocketMessage.fromJson(
jsonDecode(snapshot.data as String) as Map<String, dynamic>,
);
if (message is ChatMessage) {
return ChatBubble(message: message.message);
}
} catch (e) {
return Text('Error: $e');
}
return const SizedBox();
},
)Authentication
Connecting with Auth Token
void _connectWithToken(String token) {
final uri = Uri.parse('wss://example.com/ws').replace(
queryParameters: {'token': token},
);
_channel = WebSocketChannel.connect(uri);
}Sending Auth Header
IOWebSocketChannel works on IO platforms only. For Flutter web, use query parameters, cookies, or an application-level auth message after connecting, depending on the backend contract.
import 'package:web_socket_channel/io.dart';
void _connectWithHeaders(String token) {
final channel = IOWebSocketChannel.connect(
'wss://example.com/ws',
headers: {
'Authorization': 'Bearer $token',
'X-Client-ID': 'my-app',
},
);
_channel = channel;
}Multiple Channels
Managing Multiple Connections
class WebSocketManager {
final Map<String, WebSocketChannel> _channels = {};
void connect(String channelId, String url) {
if (_channels.containsKey(channelId)) {
_channels[channelId]?.sink.close();
}
_channels[channelId] = WebSocketChannel.connect(Uri.parse(url));
}
void send(String channelId, String message) {
_channels[channelId]?.sink.add(message);
}
Stream<dynamic> getStream(String channelId) {
return _channels[channelId]!.stream;
}
void close(String channelId) {
_channels[channelId]?.sink.close();
_channels.remove(channelId);
}
void closeAll() {
_channels.forEach((key, channel) => channel.sink.close());
_channels.clear();
}
}Error Handling
Handling WebSocket Errors
channel.stream.listen(
(data) {
print('Received: $data');
},
onError: (error) {
print('WebSocket error: $error');
},
onDone: () {
print('WebSocket connection closed');
},
cancelOnError: false,
);Best Practices
1. Always close connections - Dispose WebSocket in dispose() method 2. Handle connection states - Track connecting, connected, disconnected states 3. Implement reconnection - Add automatic reconnection with exponential backoff 4. Validate incoming data - Parse and validate JSON messages 5. Use type-safe models - Create message classes for different message types 6. Secure connections - Use wss:// (WebSocket Secure) instead of ws:// 7. Handle network changes - Reconnect on network state changes 8. Limit message size - Validate and limit incoming message sizes
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP_ROOT="${TMPDIR:-/tmp}/flutter_networking_verify_$$"
APP_DIR="$TMP_ROOT/app"
cleanup() {
rm -rf "$TMP_ROOT"
}
trap cleanup EXIT
flutter create --empty --platforms=android,ios,web "$APP_DIR"
cd "$APP_DIR"
flutter pub add http web_socket_channel shared_preferences flutter_secure_storage
cp "$ROOT_DIR"/assets/code-templates/*.dart lib/
cp "$ROOT_DIR"/assets/examples/*.dart lib/
dart format lib
dart format --output=none --set-exit-if-changed lib
flutter analyze
Related skills
FAQ
What HTTP package does flutter-networking use?
flutter-networking builds on package:http with dart:async and dart:convert. The ApiService class wraps http.Client with a configurable base URL, default 15-second timeout, and optional HeadersProvider for dynamic headers.
Does flutter-networking support typed JSON responses?
Yes. flutter-networking defines a JsonDecoder typedef so each endpoint method can parse responses into typed Dart models. HeadersProvider enables async auth token injection before requests fire.
Is Flutter Networking safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.