[{"data":1,"prerenderedAt":27},["ShallowReactive",2],{"articolo-caching-http-in-flutter-etag-cache-control-e-dio-cache-interceptor":3,"comments-article-caching-http-in-flutter-etag-cache-control-e-dio-cache-interceptor":26},{"id":4,"title":5,"slug":6,"excerpt":7,"body":8,"cover_image":9,"cover_remote_url":10,"cover_credit":11,"video_url":15,"status":16,"published_at":17,"meta_title":18,"meta_description":19,"category":20,"author":24},147,"Caching HTTP in Flutter: ETag, Cache-Control e dio_cache_interceptor","caching-http-in-flutter-etag-cache-control-e-dio-cache-interceptor","Come ridurre traffico, latenza e consumo di batteria nelle app Flutter sfruttando la cache HTTP: dalle basi di Cache-Control ed ETag all'implementazione pratica con dio_cache_interceptor, policy, invalidazione e fallback offline.","## Perché il caching HTTP conta più di quanto sembri\n\nIn 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.\n\nEsiste 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.\n\n## Le basi: freshness e validazione\n\nIl protocollo HTTP distingue due meccanismi complementari.\n\n**Freshness (Cache-Control)**: il server dichiara per quanto tempo la risposta è considerata valida.\n\n```http\nCache-Control: max-age=300, stale-while-revalidate=86400\n```\n\nPer 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.\n\n**Validazione (ETag \u002F Last-Modified)**: quando la risposta è scaduta, il client non riscarica tutto. Invia una richiesta condizionale:\n\n```http\nGET \u002Fapi\u002Farticles HTTP\u002F1.1\nIf-None-Match: \"a1b2c3d4\"\n```\n\nSe nulla è cambiato il server risponde `304 Not Modified` con corpo vuoto: si risparmiano banda e parsing JSON, non il round-trip.\n\nAltri 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).\n\n## Installazione e setup minimo\n\n```yaml\ndependencies:\n  dio: ^5.7.0\n  dio_cache_interceptor: ^3.5.0\n  dio_cache_interceptor_hive_store: ^3.2.2\n  path_provider: ^2.1.4\n```\n\nIl 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.\n\n```dart\nimport 'package:dio\u002Fdio.dart';\nimport 'package:dio_cache_interceptor\u002Fdio_cache_interceptor.dart';\nimport 'package:dio_cache_interceptor_hive_store\u002Fdio_cache_interceptor_hive_store.dart';\nimport 'package:path_provider\u002Fpath_provider.dart';\n\nFuture\u003CDio> buildDio() async {\n  final dir = await getApplicationCacheDirectory();\n  final store = HiveCacheStore('${dir.path}\u002Fhttp_cache');\n\n  final cacheOptions = CacheOptions(\n    store: store,\n    \u002F\u002F Rispetta gli header del server, con fallback se mancano\n    policy: CachePolicy.request,\n    \u002F\u002F Validità massima se il server non dice nulla\n    maxStale: const Duration(days: 7),\n    \u002F\u002F Serve la cache se la rete fallisce, tranne su questi status\n    hitCacheOnErrorExcept: const [401, 403],\n    priority: CachePriority.normal,\n    keyBuilder: CacheOptions.defaultCacheKeyBuilder,\n    allowPostMethod: false,\n  );\n\n  final dio = Dio(BaseOptions(baseUrl: 'https:\u002F\u002Fapi.example.com'));\n  dio.interceptors.add(DioCacheInterceptor(options: cacheOptions));\n  return dio;\n}\n```\n\nDa 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`\u002F`If-Modified-Since` e, in caso di `304`, l'interceptor ricostruisce la risposta dalla cache.\n\n> Nota sulle versioni: nelle release più recenti del pacchetto `hitCacheOnErrorExcept` è affiancato\u002Fsostituito da `hitCacheOnNetworkFailure`. Controlla il changelog della versione che blocchi nel `pubspec.yaml`.\n\n## Le policy: scegliere il comportamento giusto\n\n`CachePolicy` è il parametro che determina tutto. In sintesi:\n\n- `request` (default consigliato): rispetta gli header HTTP del server; se mancano usa `maxStale`.\n- `forceCache`: usa la cache se disponibile e non scaduta secondo `maxStale`, ignorando le direttive del server. Utile per endpoint quasi statici (configurazioni, cataloghi, tassonomie).\n- `refresh`: forza la chiamata di rete ma aggiorna la cache. È la policy del pull-to-refresh.\n- `refreshForceCache`: chiama sempre la rete e, in caso di errore, ricade sulla cache.\n- `noCache`: richiesta sempre eseguita e risposta non memorizzata. Obbligatoria per endpoint sensibili (profilo, saldo, token).\n\nLe opzioni si possono sovrascrivere per singola richiesta:\n\n```dart\nclass ArticleApi {\n  ArticleApi(this._dio, this._cacheOptions);\n\n  final Dio _dio;\n  final CacheOptions _cacheOptions;\n\n  Future\u003CList\u003CArticle>> fetchArticles({bool forceRefresh = false}) async {\n    final options = _cacheOptions\n        .copyWith(\n          policy: forceRefresh ? CachePolicy.refresh : CachePolicy.request,\n        )\n        .toOptions();\n\n    final response = await _dio.get\u003CList\u003Cdynamic>>('\u002Farticles', options: options);\n    return response.data!\n        .map((json) => Article.fromJson(json as Map\u003CString, dynamic>))\n        .toList();\n  }\n\n  Future\u003CAppConfig> fetchConfig() async {\n    final options = _cacheOptions\n        .copyWith(\n          policy: CachePolicy.forceCache,\n          maxStale: const Nullable(Duration(hours: 12)),\n        )\n        .toOptions();\n\n    final response = await _dio.get\u003CMap\u003CString, dynamic>>('\u002Fconfig', options: options);\n    return AppConfig.fromJson(response.data!);\n  }\n}\n```\n\nIl wrapper `Nullable` serve perché `copyWith` deve poter distinguere \"non specificato\" da \"impostato a null\".\n\n## Sapere da dove arriva una risposta\n\nPer debug e UI (\"dati aggiornati 5 minuti fa\") l'interceptor popola gli `extra` della risposta:\n\n```dart\nfinal response = await dio.get('\u002Farticles');\nfinal fromCache = response.extra[CacheResponse.cacheKey] != null;\nfinal cachedAt = response.extra[CacheResponse.fromNetwork] == false;\n\ndebugPrint('status: ${response.statusCode}, servita da cache: $fromCache');\n```\n\nUn 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.\n\n## Invalidazione: la parte difficile\n\nLa 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.\n\n```dart\nclass CacheInvalidationInterceptor extends Interceptor {\n  CacheInvalidationInterceptor(this._store, this._options);\n\n  final CacheStore _store;\n  final CacheOptions _options;\n\n  static const _rules = \u003CString, List\u003CString>>{\n    '\u002Farticles': ['\u002Farticles', '\u002Ffeed'],\n    '\u002Fprofile': ['\u002Fprofile'],\n  };\n\n  @override\n  Future\u003Cvoid> onResponse(Response response, ResponseInterceptorHandler handler) async {\n    final method = response.requestOptions.method.toUpperCase();\n    final isMutation = method == 'POST' || method == 'PUT' ||\n        method == 'PATCH' || method == 'DELETE';\n\n    if (isMutation && response.statusCode != null && response.statusCode! \u003C 300) {\n      final path = response.requestOptions.path;\n      final targets = _rules.entries\n          .where((e) => path.startsWith(e.key))\n          .expand((e) => e.value);\n\n      for (final target in targets) {\n        await _store.deleteFromPath(RegExp('.*$target.*'));\n      }\n    }\n    handler.next(response);\n  }\n}\n```\n\n`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.\n\n```dart\nFuture\u003Cvoid> onLogout() async {\n  await store.clean(priorityOrBelow: CachePriority.high);\n  \u002F\u002F più eventuali altre pulizie (secure storage, db locale)\n}\n```\n\n## Cosa non mettere mai in cache\n\n- Endpoint di autenticazione, token, refresh token.\n- Dati personali o finanziari che non devono persistere su disco: usa `CachePolicy.noCache`, oppure uno store in memoria dedicato.\n- 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`.\n\n```dart\nString myKeyBuilder(RequestOptions request) {\n  final lang = request.headers['Accept-Language'] ?? 'it';\n  final base = CacheOptions.defaultCacheKeyBuilder(request);\n  return '$base|$lang';\n}\n```\n\nLa firma di `defaultCacheKeyBuilder` è cambiata tra le major del pacchetto (prima accettava `url` e `headers`): adatta il codice alla versione in uso.\n\n## Cache e stati offline\n\nIl 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 è:\n\n1. `hitCacheOnErrorExcept` attivo, così un `DioException` di tipo `connectionError` restituisce comunque dati.\n2. In UI, mostrare un banner quando la risposta arriva da cache e la rete non è disponibile.\n3. Un `maxStale` ragionato per endpoint: ore per i contenuti editoriali, minuti per i dati operativi.\n\n```dart\ntry {\n  final articles = await api.fetchArticles();\n  emit(ArticlesLoaded(articles, stale: !api.lastResponseFromNetwork));\n} on DioException catch (e) {\n  if (e.type == DioExceptionType.connectionError) {\n    emit(const ArticlesError('Nessuna connessione e nessun dato in cache'));\n  } else {\n    rethrow;\n  }\n}\n```\n\n## Checklist finale\n\n- Verifica con uno strumento HTTP (curl, Charles, Proxyman) che il backend invii `Cache-Control` ed `ETag`. Senza di questi, la cache client è cieca.\n- Usa `CachePolicy.request` come default e deroga solo dove serve.\n- Imposta sempre un `maxStale` di sicurezza: evita entry immortali.\n- Invalida dopo le mutazioni e svuota al logout.\n- Scegli lo store in base ai dati: `MemCacheStore` per sessioni brevi, Hive\u002FDrift per persistenza fra avvii.\n- 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.\n\nCon 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.","https:\u002F\u002Fflutter.it\u002Fstorage\u002Farticles\u002F93e7770e-9484-422b-885f-10ded4fa1153.jpg","https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1507333199169-84fd735371fb?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w5NzA2NTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3OTM2Nzg0OTl8&ixlib=rb-4.1.0&q=80&w=1080",{"name":12,"author_url":13,"photo_url":14},"imgix","https:\u002F\u002Funsplash.com\u002F@imgix","https:\u002F\u002Funsplash.com\u002Fphotos\u002Fa-close-up-of-a-bunch-of-wires-in-a-rack-tebFqdANuxs",null,"published","2026-11-03T04:01:40+00:00","Caching HTTP in Flutter con dio_cache_interceptor","Guida pratica al caching HTTP in Flutter: Cache-Control, ETag e 304, policy, invalidazione e fallback offline con Dio e dio_cache_interceptor.",{"id":21,"name":22,"slug":23},1,"Guide","guide",{"id":21,"name":25},"Flutter Bot",[],1793710663603]