Best Practice #
Building a good networking system in Flutter isn’t just about ensuring the async request you send successfully returns a 200 OK status. The internet is very volatile and unpredictable. The real challenge is how your app still behaves gracefully when the internet connection is slow, when the signal cuts out mid-way, when the backend API server is overloaded, or when users switch pages quickly in the middle of a still-pending request process.
Ignoring network behavior details can ruin the overall user experience, creating the perception that your app is slow, unresponsive, or unstable. This article summarizes proven design patterns and best practices to make your Flutter app’s networking layer feel fast, reliable, memory-efficient, and professional.
In this closing guide of the networking section, we’ll discuss modern caching strategies, offline-first architecture, optimistic UI updates, secure multi-environment configuration, sanitized logging, anti-pattern handling, and a network review checklist.
1. Caching Strategy: Stale-While-Revalidate #
One of the most effective ways to make your app feel instant and responsive is implementing a smart caching strategy. Requiring users to see loading animations (shimmer/spinner) every time they open the same page is a waste of time and bandwidth.
The modern caching strategy that’s very popular in the industry is Stale-While-Revalidate (SWR). The basic concept is:
- Stale: The client immediately serves the old data stored in the local cache to the user the moment the screen opens (so the UI renders instantly without loading).
- Revalidate: In the background, the client quietly sends an internet network request to the server to fetch the latest data.
- Update: Once the fresh data from the server arrives, the client updates the local cache database and smoothly updates the UI display.
Here’s a flow diagram of how data flows in the Stale-While-Revalidate strategy to guarantee instant interface rendering:
graph TD
classDef default stroke:#333,stroke-width:2px;
A["Client Requests Data"] --> B{"Is there a Cache?"}
B -->|Yes| C["1. Emit Cache Data Immediately to UI (UI Renders Fast)"]
B -->|No| D["2. Show Loading Indicator"]
C --> E["3. Hit Server in the Background"]
D --> E
E --> F{"Server Hit Successful?"}
F -->|Yes| G["4. Update Local Cache DB & Emit New Data to UI"]
F -->|No| H{"Did we use Cache Data earlier?"}
H -->|Yes| I["Keep showing Cache & Notify Offline Banner"]
H -->|No| J["Show Error Screen (ErrorView)"]Implementing the SWR Pattern with Dart Streams #
You can apply the SWR pattern by leveraging the Stream feature in Dart combined with StreamProvider in Riverpod:
class ProductRepository {
final ProductRemoteDataSource _remote;
final ProductLocalDataSource _local;
ProductRepository(this._remote, this._local);
// Returns a Stream so it can emit cache data then fresh data sequentially
Stream<List<Product>> watchProductList() async* {
// Step 1: Emit the local cache data immediately (if any)
final localCache = await _local.fetchProductCache();
if (localCache != null) {
yield localCache.map((dto) => dto.toDomain()).toList();
}
// Step 2: Revalidate the data to the backend server in the background
try {
final freshDtos = await _remote.getProducts();
await _local.saveProductCache(freshDtos);
// Emit the latest data from the server
yield freshDtos.map((dto) => dto.toDomain()).toList();
} catch (e) {
// If the fetch fails and there's no cache at all, throw the error to the UI
if (localCache == null) rethrow;
// If the old cache data is already displayed, let it be (users can still see old data)
}
}
}
2. Offline-First Architecture #
Great mobile apps must remain functional even when the user’s device isn’t connected to the internet at all. To build a true offline-first app, you must dynamically detect the device’s connection status and provide a data fallback flow to the local database.
You use the connectivity_plus library to monitor the device’s network status:
import 'package:connectivity_plus/connectivity_plus.dart';
class ConnectionService {
final Connectivity _connectivity = Connectivity();
// Monitor connection status changes in real-time
Stream<bool> get connectionStatusStream => _connectivity.onConnectivityChanged.map(
(result) => result != ConnectivityResult.none,
);
// Check the current instant connection status
Future<bool> get isOnline async {
final result = await _connectivity.checkConnectivity();
return result != ConnectivityResult.none;
}
}
Transparent Data Fallback in the Repository #
class NewsRepository {
final NewsRemoteDataSource _remote;
final NewsLocalDataSource _local;
final ConnectionService _connection;
NewsRepository(this._remote, this._local, this._connection);
Future<List<News>> fetchMainNews() async {
final isOnline = await _connection.isOnline;
if (!isOnline) {
// Offline Scenario: Load old data from the local cache database
final localCache = await _local.fetchNewsCache();
if (localCache != null) return localCache;
throw const NetworkException('No internet connection and the local cache is empty.');
}
try {
final freshData = await _remote.fetchNews();
await _local.saveNewsCache(freshData);
return freshData;
} catch (_) {
// Network Down Scenario: If the API server fails, fall back to the local cache
final localCache = await _local.fetchNewsCache();
if (localCache != null) return localCache;
rethrow;
}
}
}
3. Optimistic Updates for Responsive Interfaces #
When a user presses the “Add to Favorites” or “Like” button on a post, a standard app usually shows a small loading indicator, waits for the API request to finish (1-2 seconds), then changes the button icon color. That waiting gap makes the app feel heavy and sluggish.
Professional apps apply the Optimistic Update technique. The mindset is: assume the async request to the server will definitely succeed. You immediately update the UI display and local cache database right after the button is clicked. If the request to the server turns out to fail mid-way (e.g., connection dies), you perform a roll-back process to restore the UI status to its previous state and show an error notification to the user.
Here’s the Optimistic Update implementation on the shopping cart feature:
class OptimisticCartNotifier extends AsyncNotifier<List<CartItem>> {
@override
Future<List<CartItem>> build() async {
return ref.watch(cartRepositoryProvider).fetchCart();
}
Future<void> addShoppingItem(Product product) async {
// 1. Save a copy of the old state before mutating for roll-back backup
final oldState = state;
final newItem = CartItem(product: product, quantity: 1);
// 2. Modify the UI state optimistically (directly update the UI without waiting for the API)
state = AsyncValue.data([
...?state.valueOrNull,
newItem,
]);
try {
// 3. Execute the actual API call to the server
final latestList = await ref.read(cartRepositoryProvider).addItemApi(product.id);
// 4. Update the state with the official data from the server
state = AsyncValue.data(latestList);
} catch (e) {
// 5. ROLLBACK: If the server fails, return the UI to its original condition
state = oldState;
// Apply error handling (like showing a Toast to the user)
rethrow;
}
}
}
4. Environment Configuration Without Hardcoding #
Hardcoding API URL addresses directly inside network client files (baseUrl: 'https://api.ourstore.com') is a very dangerous bad habit. Development, Staging, and Production server URLs must be separated to avoid accidentally deleting or filling production databases during development.
You can manage this environment configuration safely using structured enums in Flutter:
// core/config/app_config.dart
enum EnvironmentType { development, staging, production }
class AppConfig {
static late EnvironmentType _env;
static late String _baseUrl;
static late bool _showConsoleLog;
static void initialize(EnvironmentType env) {
_env = env;
switch (env) {
case EnvironmentType.development:
_baseUrl = 'https://dev-api.ourstore.com/v1';
_showConsoleLog = true;
case EnvironmentType.staging:
_baseUrl = 'https://staging-api.ourstore.com/v1';
_showConsoleLog = true;
case EnvironmentType.production:
_baseUrl = 'https://api.ourstore.com/v1';
_showConsoleLog = false; // Turn off log printing in production for performance
}
}
static String get baseUrl => _baseUrl;
static bool get showLog => _showConsoleLog;
static bool get isProduction => _env == EnvironmentType.production;
}
Using Different Entry Points (Multi-Main Files) #
You create several different main entry point files to trigger environment-specific compilation:
// main_development.dart
void main() {
AppConfig.initialize(EnvironmentType.development);
runApp(const OurApp());
}
// main_production.dart
void main() {
AppConfig.initialize(EnvironmentType.production);
runApp(const OurApp());
}
When running the app from the terminal, you just choose the entry file you want:
flutter run -t lib/main_development.dart
5. Safe & Informative Network Logging #
Printing HTTP request and response details to the console log (debugging logging) helps you speed up bug tracking during development. However, printing all raw information plainly is very dangerous for app security. Sensitive user credential information like login passwords, JWT tokens, or credit card numbers can leak into device log systems.
You must design a custom Sanitized Logging Interceptor that automatically censors those sensitive data before printing to the log.
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
class SanitizedLoggingInterceptor extends Interceptor {
final bool active;
SanitizedLoggingInterceptor({required this.active});
// The collection of JSON key names whose data we must censor
final List<String> _sensitiveKeyList = [
'password',
'token',
'access_token',
'refresh_token',
'credit_card_number',
'cvv'
];
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (!active) return handler.next(options);
debugPrint('┌── [HTTP REQUEST] ────────────────────────────────');
debugPrint('│ METHOD : ${options.method}');
debugPrint('│ URL : ${options.uri}');
if (options.data != null) {
final censoredData = _censorSensitiveData(options.data);
debugPrint('│ PAYLOAD: $censoredData');
}
debugPrint('└──────────────────────────────────────────────────');
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
if (!active) return handler.next(response);
debugPrint('┌── [HTTP RESPONSE] ───────────────────────────────');
debugPrint('│ STATUS : ${response.statusCode}');
debugPrint('│ URL : ${response.requestOptions.uri}');
debugPrint('└──────────────────────────────────────────────────');
handler.next(response);
}
// Recursive function to hide sensitive data inside JSON Maps
dynamic _censorSensitiveData(dynamic data) {
if (data is Map) {
return Map.from(data).map((key, value) {
final keyString = key.toString().toLowerCase();
if (_sensitiveKeyList.contains(keyString)) {
return MapEntry(key, '*** [CENSORED DATA] ***');
}
// If the value is a nested map, call this function again recursively
return MapEntry(key, _censorSensitiveData(value));
});
}
return data;
}
}
6. Consistent Pagination Strategy #
Displaying very long data lists (like transaction histories or product catalogs) requires you to apply gradual data loading techniques (Pagination).
Why Choose Cursor-Based Pagination? #
There are two main pagination strategies:
- Offset-Based Pagination: The client sends
pageandlimitparameters (e.g., fetch page 2 with a limit of 10). This strategy is very easy to implement, but prone to duplicate or missed data problems if new data enters the server while the user is scrolling the screen. - Cursor-Based Pagination (Recommended): The client doesn’t request a numeric page, but sends a unique ID pointer of the last data previously obtained (cursor). This strategy is very stable and consistent because the server fetches new data precisely based on the last ID position, no matter how much new data is added above it.
Here’s a cursor-based pagination controller framework:
class ProductPaginationNotifier extends AsyncNotifier<List<Product>> {
String? _nextCursor;
bool _hasMoreData = true;
@override
Future<List<Product>> build() async {
return _loadPage(null);
}
Future<List<Product>> _loadPage(String? cursor) async {
final repository = ref.read(productRepositoryProvider);
final responseResult = await repository.fetchProductsPaginated(cursor: cursor);
_nextCursor = responseResult.nextCursor;
_hasMoreData = responseResult.hasMoreData;
return responseResult.productList;
}
Future<void> loadNextPage() async {
// Prevent double calls if the loading process is running or the data is exhausted
if (!_hasMoreData || state.isLoading) return;
final oldList = state.valueOrNull ?? [];
// Set the status to loading without removing the old data from the UI
state = const AsyncLoading();
try {
final newList = await _loadPage(_nextCursor);
// Combine the old data with the new data
state = AsyncValue.data([...oldList, ...newList]);
} catch (e, stack) {
state = AsyncError(e, stack);
}
}
}
7. Networking Anti-Patterns You Must Avoid #
While designing your Flutter app’s network architecture, make sure to avoid the following bad habits (anti-patterns):
- Repeatedly Instantiating Dio: Creating a new
final dio = Dio()object in every API class wastes RAM usage and triggers TCP connection file descriptor leaks. Always use the Singleton pattern for the DioClient instance. - Hardcoding Base URL Addresses: Writing raw physical URL strings in many places complicates server migration processes and risks accidentally hitting the production database. Use a unified
AppConfigclass. - Ignoring Timeout Limits: Leaving the timeout property empty makes your app hang forever (infinite loading) when users are on a completely dead internet network or behind an airport Wi-Fi captive portal. Always set timeout limits in
BaseOptions. - Silent Catch: Writing empty
catch (e) {}blocks without flowing errors to the UI makes the app appear unresponsive to user commands when network failures occur. - Forgetting to Include CancelTokens: Letting search requests keep running while users type new letters quickly wastes device battery power and your backend server’s bandwidth.
- Parsing JSON in Build Methods: Parsing DTO Maps into class objects inside a widget’s
build()method significantly degrades screen rendering performance because parsing happens repeatedly on every UI frame drawn. Do parsing in the Data Source or Repository layer. - Storing Sensitive Credentials in Plain SharedPreferences: Writing JWT Tokens or user passwords to plain Shared Preferences makes it easy for hackers to steal that data on rooted devices. Always use
flutter_secure_storage.
8. Network Layer Review Checklist #
Use the following structured checklist during the code review process to ensure your Flutter app’s networking layer is ready to ship to the market:
Construction & Configuration: #
- Is the DioClient instance initialized using the Singleton pattern?
- Are the
connectTimeout,sendTimeout, andreceiveTimeoutlimits safely configured? - Is the base URL dynamically fetched from the environment configuration class?
- Does the HTTP Client library use the secure HTTPS protocol for all endpoints?
Error Handling & Security: #
- Are all network errors (Offline, Timeout, Bad Response) centrally mapped?
- Is JWT Token expired (401) handling integrated to force logout and automatic redirection to the login screen?
- Are Access Tokens and Refresh Tokens stored securely inside
flutter_secure_storage? - Is console logging filtered so sensitive user credential data doesn’t leak in the terminal?
Performance & User Experience (UX): #
- Is rarely changing data (like country lists) stored in the local cache using a caching strategy?
- Are search-as-you-type and abandoned async requests secured with
CancelToken? - On temporary connection failures, does the system do automatic retries before throwing errors?
- Does the
ErrorViewwidget provide a user-friendly retry button option?
Summary #
- Stale-While-Revalidate (SWR) pampers users by serving local cache data instantly right after the page opens, then smoothly refreshing fresh data in the background.
- Offline-First Support guarantees the app remains functional without an internet network by serving encrypted local data complete with an informative banner indicator.
- Optimistic Updates improve the app’s responsive feel by directly updating UI visuals before the server response finishes, and performing roll-backs if failures occur.
- Centralized AppConfig safely separates Development, Staging, and Production API routes to avoid the risk of accidentally writing test data to the production server.
- Sanitized Logging protects sensitive user data by filtering credential keys from terminal logs during debugging.
- Cursor-Based Pagination delivers consistent gradual data loading without duplication or missed-data risks when database rows undergo active updates.
- Anti-Pattern Cleanup: Always set connection timeouts, apply the singleton client, use secure storage, limit requests with CancelTokens, and avoid silently swallowing async errors.