Perché il caching HTTP conta più di quanto sembri

In molte app Flutter il collo di bottiglia non è il rendering, ma la rete: schermate che rifanno la stessa GET a ogni apertura, liste che si ricaricano da zero dopo un pop, utenti in metropolitana che vedono solo spinner. La soluzione più spesso adottata è scrivere una cache "a mano" in SharedPreferences o in un database locale, con chiavi stringa e logica di scadenza sparsa nei repository.

Esiste però un livello già standardizzato, testato da decenni e gratuito: la cache HTTP. Se il backend espone gli header giusti, il client può riutilizzare le risposte senza nemmeno interrogare il server, oppure validarle con una richiesta leggerissima che restituisce 304 Not Modified. In Flutter possiamo innestare questo comportamento direttamente in Dio con dio_cache_interceptor, senza toccare i repository.

Le basi: freshness e validazione

Il protocollo HTTP distingue due meccanismi complementari.

Freshness (Cache-Control): il server dichiara per quanto tempo la risposta è considerata valida.

Cache-Control: max-age=300, stale-while-revalidate=86400

Per 300 secondi la risposta è "fresca" e il client può servirla senza contattare il server. Nelle 24 ore successive può servirla comunque (stale) mentre aggiorna in background.

Validazione (ETag / Last-Modified): quando la risposta è scaduta, il client non riscarica tutto. Invia una richiesta condizionale:

GET /api/articles HTTP/1.1
If-None-Match: "a1b2c3d4"

Se nulla è cambiato il server risponde 304 Not Modified con corpo vuoto: si risparmiano banda e parsing JSON, non il round-trip.

Altri valori utili di Cache-Control lato risposta: no-store (non memorizzare mai, obbligatorio per dati sensibili), no-cache (memorizza ma rivalida sempre), private (solo cache client).

Installazione e setup minimo

dependencies:
  dio: ^5.7.0
  dio_cache_interceptor: ^3.5.0
  dio_cache_interceptor_hive_store: ^3.2.2
  path_provider: ^2.1.4

Il pacchetto separa due concetti: le opzioni (CacheOptions, cosa e per quanto memorizzare) e lo store (dove scrivere). Gli store disponibili includono MemCacheStore (solo RAM), HiveCacheStore, FileCacheStore e store basati su Drift.

import 'package:dio/dio.dart';
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
import 'package:path_provider/path_provider.dart';

Future<Dio> buildDio() async {
  final dir = await getApplicationCacheDirectory();
  final store = HiveCacheStore('${dir.path}/http_cache');

  final cacheOptions = CacheOptions(
    store: store,
    // Rispetta gli header del server, con fallback se mancano
    policy: CachePolicy.request,
    // Validità massima se il server non dice nulla
    maxStale: const Duration(days: 7),
    // Serve la cache se la rete fallisce, tranne su questi status
    hitCacheOnErrorExcept: const [401, 403],
    priority: CachePriority.normal,
    keyBuilder: CacheOptions.defaultCacheKeyBuilder,
    allowPostMethod: false,
  );

  final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
  dio.interceptors.add(DioCacheInterceptor(options: cacheOptions));
  return dio;
}

Da questo momento ogni GET passa dall'interceptor: se esiste una entry fresca viene restituita senza rete, altrimenti parte una richiesta condizionale con If-None-Match/If-Modified-Since e, in caso di 304, l'interceptor ricostruisce la risposta dalla cache.

Nota sulle versioni: nelle release più recenti del pacchetto hitCacheOnErrorExcept è affiancato/sostituito da hitCacheOnNetworkFailure. Controlla il changelog della versione che blocchi nel pubspec.yaml.

Le policy: scegliere il comportamento giusto

CachePolicy è il parametro che determina tutto. In sintesi:

  • request (default consigliato): rispetta gli header HTTP del server; se mancano usa maxStale.
  • forceCache: usa la cache se disponibile e non scaduta secondo maxStale, ignorando le direttive del server. Utile per endpoint quasi statici (configurazioni, cataloghi, tassonomie).
  • refresh: forza la chiamata di rete ma aggiorna la cache. È la policy del pull-to-refresh.
  • refreshForceCache: chiama sempre la rete e, in caso di errore, ricade sulla cache.
  • noCache: richiesta sempre eseguita e risposta non memorizzata. Obbligatoria per endpoint sensibili (profilo, saldo, token).

Le opzioni si possono sovrascrivere per singola richiesta:

class ArticleApi {
  ArticleApi(this._dio, this._cacheOptions);

  final Dio _dio;
  final CacheOptions _cacheOptions;

  Future<List<Article>> fetchArticles({bool forceRefresh = false}) async {
    final options = _cacheOptions
        .copyWith(
          policy: forceRefresh ? CachePolicy.refresh : CachePolicy.request,
        )
        .toOptions();

    final response = await _dio.get<List<dynamic>>('/articles', options: options);
    return response.data!
        .map((json) => Article.fromJson(json as Map<String, dynamic>))
        .toList();
  }

  Future<AppConfig> fetchConfig() async {
    final options = _cacheOptions
        .copyWith(
          policy: CachePolicy.forceCache,
          maxStale: const Nullable(Duration(hours: 12)),
        )
        .toOptions();

    final response = await _dio.get<Map<String, dynamic>>('/config', options: options);
    return AppConfig.fromJson(response.data!);
  }
}

Il wrapper Nullable serve perché copyWith deve poter distinguere "non specificato" da "impostato a null".

Sapere da dove arriva una risposta

Per debug e UI ("dati aggiornati 5 minuti fa") l'interceptor popola gli extra della risposta:

final response = await dio.get('/articles');
final fromCache = response.extra[CacheResponse.cacheKey] != null;
final cachedAt = response.extra[CacheResponse.fromNetwork] == false;

debugPrint('status: ${response.statusCode}, servita da cache: $fromCache');

Un interceptor di logging dedicato aiuta a verificare che il backend stia davvero emettendo ETag e Cache-Control: senza quegli header la cache funziona solo grazie a maxStale, cioè "a tempo", senza validazione.

Invalidazione: la parte difficile

La regola pratica è: dopo ogni mutazione, invalida le risorse che dipendono da quella mutazione. Con un interceptor dedicato si evita di sparpagliare la logica nei repository.

class CacheInvalidationInterceptor extends Interceptor {
  CacheInvalidationInterceptor(this._store, this._options);

  final CacheStore _store;
  final CacheOptions _options;

  static const _rules = <String, List<String>>{
    '/articles': ['/articles', '/feed'],
    '/profile': ['/profile'],
  };

  @override
  Future<void> onResponse(Response response, ResponseInterceptorHandler handler) async {
    final method = response.requestOptions.method.toUpperCase();
    final isMutation = method == 'POST' || method == 'PUT' ||
        method == 'PATCH' || method == 'DELETE';

    if (isMutation && response.statusCode != null && response.statusCode! < 300) {
      final path = response.requestOptions.path;
      final targets = _rules.entries
          .where((e) => path.startsWith(e.key))
          .expand((e) => e.value);

      for (final target in targets) {
        await _store.deleteFromPath(RegExp('.*$target.*'));
      }
    }
    handler.next(response);
  }
}

CacheStore espone anche clean() per svuotare tutto (utile al logout) e delete(key) per una singola entry, dove la chiave è quella prodotta dal keyBuilder. Attenzione: al logout la pulizia della cache non è opzionale, altrimenti l'utente successivo potrebbe vedere dati altrui.

Future<void> onLogout() async {
  await store.clean(priorityOrBelow: CachePriority.high);
  // più eventuali altre pulizie (secure storage, db locale)
}

Cosa non mettere mai in cache

  • Endpoint di autenticazione, token, refresh token.
  • Dati personali o finanziari che non devono persistere su disco: usa CachePolicy.noCache, oppure uno store in memoria dedicato.
  • Risposte che variano in base a header non inclusi nella chiave (es. Accept-Language, tenant ID). In questi casi personalizza il keyBuilder includendo gli header rilevanti, oppure assicurati che il server invii Vary.
String myKeyBuilder(RequestOptions request) {
  final lang = request.headers['Accept-Language'] ?? 'it';
  final base = CacheOptions.defaultCacheKeyBuilder(request);
  return '$base|$lang';
}

La firma di defaultCacheKeyBuilder è cambiata tra le major del pacchetto (prima accettava url e headers): adatta il codice alla versione in uso.

Cache e stati offline

Il caching HTTP non sostituisce un'architettura offline-first con database locale e sincronizzazione, ma copre molto bene il caso "lettura che deve funzionare anche senza rete". La combinazione tipica è:

  1. hitCacheOnErrorExcept attivo, così un DioException di tipo connectionError restituisce comunque dati.
  2. In UI, mostrare un banner quando la risposta arriva da cache e la rete non è disponibile.
  3. Un maxStale ragionato per endpoint: ore per i contenuti editoriali, minuti per i dati operativi.
try {
  final articles = await api.fetchArticles();
  emit(ArticlesLoaded(articles, stale: !api.lastResponseFromNetwork));
} on DioException catch (e) {
  if (e.type == DioExceptionType.connectionError) {
    emit(const ArticlesError('Nessuna connessione e nessun dato in cache'));
  } else {
    rethrow;
  }
}

Checklist finale

  • Verifica con uno strumento HTTP (curl, Charles, Proxyman) che il backend invii Cache-Control ed ETag. Senza di questi, la cache client è cieca.
  • Usa CachePolicy.request come default e deroga solo dove serve.
  • Imposta sempre un maxStale di sicurezza: evita entry immortali.
  • Invalida dopo le mutazioni e svuota al logout.
  • Scegli lo store in base ai dati: MemCacheStore per sessioni brevi, Hive/Drift per persistenza fra avvii.
  • Misura: il vero indicatore di successo è il calo delle richieste di rete e del tempo al primo frame utile, non il numero di righe di codice risparmiate.

Con poche decine di righe di configurazione si ottiene un comportamento che, scritto a mano, richiederebbe un modulo intero: meno traffico, apertura delle schermate quasi istantanea e un'app che continua a essere utilizzabile quando la connessione viene meno.