Start Debugging

Correção: Flutter web serve um build antigo do cache depois de recarregar a aba do navegador

Um reload só revalida o index.html, então um main.dart.js sem hash continua vindo do cache do navegador. Envie Cache-Control: no-cache para a saída do build do Flutter, carimbe um build id onde você não consegue definir headers e deixe o service worker de autolimpeza aposentar os caches anteriores ao 3.41.

Resposta curta: o Flutter web gera pontos de entrada com nomes de arquivo fixos (flutter_bootstrap.js, main.dart.js, main.dart.wasm, canvaskit/...), e um reload normal do navegador só revalida o documento HTML. Se o seu host envia qualquer tempo de validade para esses arquivos (o Firebase Hosting envia max-age=3600, o GitHub Pages envia max-age=600), a página recarregada recebe um index.html novo e um main.dart.js antigo, vindo do cache. Corrija servindo a pasta build/web inteira com Cache-Control: no-cache ou, em hosts onde você não consegue definir headers, carimbando um build id em flutter_bootstrap.js e main.dart.js depois do flutter build web. Se os usuários ainda têm o service worker offline-first do Flutter 3.38 ou anterior, continue implantando o flutter_service_worker.js padrão: desde o Flutter 3.41 ele é um worker de autolimpeza que cancela o registro do antigo e recarrega a aba.

Tudo o que segue foi reproduzido com Flutter 3.44.8 (Dart 3.12.2) e conferido com o código-fonte do tool e da engine da versão 3.47.3, que se comportam da mesma forma para este problema. Os testes de navegador rodaram em um navegador baseado em Chromium contra um pequeno servidor Node capaz de alternar sua política de Cache-Control.

Dois caches diferentes, dependendo de quando você publicou pela primeira vez

Os resultados de busca para este problema misturam duas épocas, e a correção é diferente:

Você pode confirmar em qual mundo está abrindo DevTools, Application, Service workers. Nenhum registro significa que o culpado é o cache HTTP.

Por que um reload não busca o novo main.dart.js

Veja o que o flutter build web coloca em build/web na 3.44.8:

# flutter build web, Flutter 3.44.8
index.html
flutter_bootstrap.js
flutter.js
flutter_service_worker.js
main.dart.js
version.json
manifest.json
assets/AssetManifest.bin
assets/FontManifest.json
assets/fonts/MaterialIcons-Regular.otf
canvaskit/canvaskit.js
canvaskit/canvaskit.wasm

Nenhum desses nomes contém um hash de conteúdo. O index.html carrega o flutter_bootstrap.js, que carrega um _flutter.buildConfig cujo mainJsPath é a string literal "main.dart.js". Toda implantação reutiliza as mesmas URLs, então o navegador não consegue distinguir um build novo de um antigo apenas pela URL.

Agora combine isso com o funcionamento do reload. O reload do Chrome revalida o recurso principal e depois faz um carregamento de página normal. O texto de 2017 da equipe do Chromium diz que o navegador optou por “only validate the main resource and continue with a regular page load” (blog do Chromium). Sub-recursos que ainda estão válidos segundo o seu Cache-Control vêm direto do cache em disco, sem requisição. Em uma navegação simples (um favorito, uma URL digitada, um link), todo navegador reutiliza cópias válidas, incluindo o próprio index.html.

Então, se o reload mostra ou não o build novo depende inteiramente do que o seu host envia para esses arquivos:

HostCache-Control padrão para arquivos estáticosJanela de conteúdo antigo após uma implantação
Firebase Hostingmax-age=3600 (observado em *.firebaseapp.com)até 1 hora
GitHub Pagesmax-age=600, não configurávelaté 10 minutos
Netlify, Vercel, Cloudflare Pagespublic, max-age=0, must-revalidatenenhuma
Nginx, Apache, python -m http.server sem configuraçãonenhum header, mas Last-Modified é enviadoheurística, veja abaixo

A última linha pega muita gente. A ausência do header Cache-Control não significa “não faça cache”. Com um header Last-Modified, a RFC 9111 seção 4.2.2 permite que o navegador escolha um tempo de validade heurístico, normalmente 10% do tempo desde a última modificação do arquivo. Um main.dart.js implantado pela última vez há dez dias pode ser tratado como válido por um dia inteiro.

O Firebase de fato limpa sua CDN a cada implantação, então a borda serve os arquivos novos imediatamente. O cache do próprio navegador não é limpo, e é essa cópia que o reload usa.

Reprodução mínima

Este servidor serve build/web com uma política alternável. firebase imita o padrão do Firebase Hosting, fixed é a correção:

// server.mjs, Node 22. Usage: MODE=firebase node server.mjs build/web
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';

const root = process.argv[2];
const types = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript',
  '.wasm': 'application/wasm', '.json': 'application/json' };

http.createServer((req, res) => {
  let p = decodeURIComponent(req.url.split('?')[0]);
  if (p.endsWith('/')) p += 'index.html';
  const file = path.join(root, p);
  if (!fs.existsSync(file)) { res.writeHead(404); return res.end(); }
  const stat = fs.statSync(file);
  const headers = {
    'Content-Type': types[path.extname(file)] ?? 'application/octet-stream',
    'ETag': `"${stat.size}-${stat.mtimeMs}"`,
    'Cache-Control': process.env.MODE === 'fixed' ? 'no-cache' : 'max-age=3600',
  };
  if (req.headers['if-none-match'] === headers.ETag) { res.writeHead(304, headers); return res.end(); }
  console.log(200, p);
  res.writeHead(200, headers);
  fs.createReadStream(file).pipe(res);
}).listen(8765);

E um app cuja única função é mostrar qual build está rodando:

// lib/main.dart, Flutter 3.44.8, Dart 3.12.2
import 'package:flutter/material.dart';

const build = 'A';

void main() => runApp(
  const MaterialApp(home: Scaffold(body: Center(child: Text('Build $build')))),
);

Passos: compile com build = 'A', inicie o servidor com MODE=firebase e abra http://localhost:8765/. Mude a constante para 'B', rode flutter build web de novo e recarregue a aba. A página continua dizendo “Build A”. O log do servidor para esse reload mostra uma única requisição:

200 /index.html

flutter_bootstrap.js, main.dart.js, o CanvasKit e as fontes foram todos servidos do cache do navegador. Repita a sequência inteira com MODE=fixed em uma origem nova e o reload mostra “Build B”. Um segundo reload sem nova implantação custa então uma requisição condicional por arquivo, cada uma respondida com um 304 e sem corpo.

A correção, passo a passo

  1. Sirva a saída do build do Flutter com Cache-Control: no-cache. no-cache não desativa o cache. Ele diz ao navegador para manter o arquivo, mas revalidá-lo com If-None-Match ou If-Modified-Since antes de cada uso. Arquivos inalterados custam uma ida e volta e um 304. Arquivos alterados são baixados. Aplique a index.html, flutter_bootstrap.js, flutter.js, flutter_service_worker.js, main.dart.js, main.dart.mjs, main.dart.wasm, version.json, manifest.json, tudo em assets/ e a pasta local canvaskit/. A regra correta mais simples é “tudo em build/web”.
  2. Coloque a regra na configuração do seu host. Seguem exemplos abaixo para Firebase Hosting, Nginx e o arquivo _headers usado pelo Netlify e pelo Cloudflare Pages.
  3. Espere passar um tempo de validade antigo. Headers novos só se aplicam a respostas buscadas depois da mudança. Navegadores que colocaram main.dart.js em cache sob max-age=3600 continuam usando essa cópia até a hora acabar. Publique a mudança de header uma implantação antes de precisar dela, ou combine-a com um build id (passo 4) no primeiro rollout.
  4. Onde você não consegue definir headers, carimbe um build id. O GitHub Pages é o caso comum. Reescreva as URLs dos pontos de entrada após cada build para que cada implantação tenha URLs novas.
  5. Avise as abas que já estão abertas. Headers só ajudam no próximo carregamento. Uma aba aberta há muito tempo continua rodando o build antigo até o usuário recarregar, então consulte periodicamente um pequeno arquivo com o build id e ofereça um reload.

Firebase Hosting

O FAQ do Flutter web sugere max-age=0,s-maxage=604800 para js, mjs, wasm e json, o que mantém a CDN aquecida enquanto força o navegador a revalidar. Esse padrão deixa de fora o HTML e o .bin, e dá a imagens e fontes max-age=3600, então index.html, assets/AssetManifest.bin e qualquer imagem que você substituiu com o mesmo nome ficam desatualizados por uma hora. Este firebase.json cobre o build inteiro:

{
  "hosting": {
    "public": "build/web",
    "headers": [
      {
        "source": "**",
        "headers": [
          { "key": "Cache-Control", "value": "no-cache" }
        ]
      }
    ]
  }
}

Como o Firebase limpa sua CDN na implantação, você não precisa de s-maxage para manter a borda correta. Adicione-o de volta só se medir um problema de latência.

Nginx

# nginx 1.27, serving the output of flutter build web
server {
    listen 80;
    root /var/www/app/build/web;

    location / {
        try_files $uri $uri/ /index.html;
        add_header Cache-Control "no-cache" always;
        etag on;
    }
}

Mantenha etag on (o padrão). Sem um validador, o navegador não tem com o que revalidar e baixa o arquivo completo toda vez.

Netlify e Cloudflare Pages

Os dois já usam max-age=0, must-revalidate por padrão, o que se comporta corretamente. Se uma configuração anterior ou um preset de framework adicionou um tempo de validade maior, sobrescreva-o com um arquivo _headers em web/, para que o flutter build web o copie para build/web:

# web/_headers, copied to build/web by flutter build web (Flutter 3.44)
/*
  Cache-Control: no-cache

GitHub Pages e outros hosts sem headers

Rode um pequeno script pós-build. Ele acrescenta ?v=<id> à tag de script do bootstrap e aos caminhos de build dentro de _flutter.buildConfig, e grava o id em build_id.txt para o passo 5:

#!/usr/bin/env bash
# bust.sh, run after `flutter build web` (Flutter 3.44 output layout)
set -euo pipefail
ID="${1:-$(git rev-parse --short HEAD 2>/dev/null || date +%s)}"
OUT=build/web
sed -i.bak "s|src=\"flutter_bootstrap.js\"|src=\"flutter_bootstrap.js?v=$ID\"|" "$OUT/index.html"
sed -i.bak -E "s#\"(main\.dart\.(js|wasm|mjs))\"#\"\1?v=$ID\"#g" "$OUT/flutter_bootstrap.js"
rm "$OUT"/*.bak
echo "$ID" > "$OUT/build_id.txt"

Sob uma política max-age=600, um reload após implantar um build carimbado requisitou exatamente três arquivos (index.html, flutter_bootstrap.js?v=..., main.dart.js?v=...) e mostrou o build novo, enquanto o CanvasKit e as fontes continuaram vindo do cache. Essa é a abordagem que o FAQ do Flutter descreve, observando que o Flutter não acrescenta build ids automaticamente. O próprio index.html continua sujeito ao tempo de validade de 10 minutos em uma navegação simples (um reload sempre o revalida), e assets que você substitui com o mesmo nome não são cobertos, então renomeie as imagens alteradas em vez de sobrescrevê-las.

Ofereça um reload às abas abertas

Passe o mesmo id para o app em tempo de compilação e compare-o com o build_id.txt implantado. cache: 'no-store' mantém a própria verificação fora do cache HTTP:

// lib/update_check.dart, Flutter 3.44.8, Dart 3.12.2, package:web 1.1.1
import 'dart:async';
import 'dart:js_interop';

import 'package:flutter/material.dart';
import 'package:web/web.dart' as web;

/// flutter build web --dart-define=BUILD_ID=$(git rev-parse --short HEAD)
const buildId = String.fromEnvironment('BUILD_ID', defaultValue: 'dev');

Future<bool> newBuildAvailable() async {
  try {
    final response = await web.window
        .fetch('build_id.txt'.toJS, web.RequestInit(cache: 'no-store'))
        .toDart;
    if (!response.ok) return false;
    final deployed = (await response.text().toDart).toDart.trim();
    return deployed.isNotEmpty && deployed != buildId;
  } catch (_) {
    return false; // offline or blocked: keep running the current build
  }
}

void startUpdateCheck(GlobalKey<ScaffoldMessengerState> messenger) {
  if (buildId == 'dev') return;
  Timer.periodic(const Duration(minutes: 5), (timer) async {
    if (!await newBuildAvailable()) return;
    timer.cancel();
    messenger.currentState?.showSnackBar(
      SnackBar(
        duration: const Duration(days: 1),
        content: const Text('A new version is available.'),
        action: SnackBarAction(
          label: 'Reload',
          onPressed: () => web.window.location.reload(),
        ),
      ),
    );
  });
}

Dê ao MaterialApp uma scaffoldMessengerKey e chame startUpdateCheck com ela a partir do main. Compilando com --dart-define=BUILD_ID=A e implantando um build_id.txt contendo B, newBuildAvailable() retornou true na primeira verificação. Se você não usa o bust.sh, grave o build_id.txt no CI com o mesmo id. Isso importa mais quando a API do seu backend muda junto com o frontend, porque uma aba antiga chamando uma API nova é um bug pior do que uma UI antiga.

Se você publicou o service worker antes do Flutter 3.41

Usuários que abriram seu app pela primeira vez quando ele era compilado com 3.38.x ou anterior ainda têm o worker offline-first e seu flutter-app-cache no navegador. Veja o que acontece na primeira vez que eles carregam uma implantação compilada com 3.41 ou posterior, de acordo com o código-fonte da 3.44.8 e da 3.47.3:

  1. O worker antigo ainda está no controle, então ele responde à navegação com prioridade para a rede (index.html novo), mas serve flutter_bootstrap.js e main.dart.js do Cache Storage. O usuário pode ver o build antigo por um instante.
  2. A verificação de atualização de service worker do navegador busca flutter_service_worker.js na rede. O arquivo é diferente byte a byte (agora é o worker de limpeza de 784 bytes), então ele é instalado.
  3. O worker de limpeza chama skipWaiting(), depois, em activate, chama self.registration.unregister() e navega cada janela que controla para a URL atual dela.
  4. Essa navegação acontece sem service worker, então a página carrega o build atual pelo cache HTTP. Com os headers da seção anterior, esse é o build novo.

O worker de limpeza não apaga flutter-app-cache, flutter-temp-cache nem flutter-app-manifest. Sem um worker, nada os lê, mas eles continuam ocupando armazenamento. Se isso importa para você, apague-os uma vez na inicialização pela API Cache Storage (caches.delete('flutter-app-cache') e assim por diante, via package:web).

Dois erros de implantação bloqueiam essa passagem de controle:

Se você precisa de suporte offline de verdade, o FAQ do Flutter agora diz para trazer seu próprio worker, por exemplo com Workbox. Dê a ele um nome de cache versionado e uma estratégia network-first para index.html e flutter_bootstrap.js, ou você vai recriar o problema antigo.

Armadilhas e problemas parecidos

Relacionados

Fontes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Voltar