1

Cache busting com ?v=: como uma URL nova prendeu a versão antiga por até um ano

Cache busting parece resolvido: você troca ?v=41 por ?v=42, a URL muda e o cache antigo deixa de valer. No MyIPScan, essa troca já fez o contrário. A URL nova passou a servir o arquivo velho, com Cache-Control: max-age=31536000, immutable. Ou seja, por até um ano.

Eu criei o MyIPScan, um conjunto de ferramentas de rede e privacidade hospedado no Cloudflare Pages. O site é HTML estático com JavaScript puro, sem bundler.

O setup: assets imutáveis e um token na URL

A receita é a que a MDN descreve como cache busting: HTML com no-cache, assets com cache longo e uma versão na URL. No Cloudflare Pages, os headers ficam num arquivo _headers. Um exemplo genérico, não o nosso arquivo:

/assets/*
  Cache-Control: max-age=31536000, immutable

E no HTML, também como exemplo:

<script src="/assets/app.js?v=42"></script>

O max-age deixa a cópia valer por até um ano. O immutable, definido na RFC 8246, diz ao cliente que o conteúdo não vai mudar enquanto estiver fresco, então o navegador não pergunta ao servidor se há versão nova, nem num reload comum (só um reload forçado pelo usuário passa por cima).

O que parece óbvio e não é

A primeira explicação que vem à cabeça é "o navegador do usuário está com cache velho". Não se sustenta: a URL é nova, ?v=42 nunca foi pedida antes, então o navegador não tem nada guardado para ela.

A segunda é "o HTML está em cache e ainda aponta para ?v=41". Mas aí o navegador pediria a URL antiga, e a nova nem entraria na história. Aqui a página pedia ?v=42; o problema estava na resposta que essa URL recebia.

Por que o cache busting falhou: a URL nova chegou antes do arquivo novo

O Cloudflare monta a cache key padrão com a URL completa, incluindo a query string. Então /assets/app.js?v=41 e /assets/app.js?v=42 são duas entradas diferentes no cache. É exatamente por isso que o truque do ?v= funciona.

Mas a origem de um site estático serve o arquivo pelo caminho e ignora a query string. Para ela, as duas URLs são o mesmo arquivo app.js. Se a URL com ?v=42 for pedida antes de o deploy com o app.js novo estar de fato propagado, acontece isto:

  1. Chega um pedido para /assets/app.js?v=42 (de um visitante ou do seu próprio teste).
  2. A CDN não tem essa chave e busca na origem.
  3. A origem ainda entrega o app.js antigo.
  4. A CDN guarda o arquivo antigo sob a chave nova, com max-age=31536000, immutable.
  5. O navegador do visitante guarda a mesma coisa e, por causa do immutable, não revalida.

O token novo, que deveria trazer a versão nova, passa a ser o endereço oficial da versão velha. E um novo deploy que mexa só no arquivo não resolve sozinho, porque a chave ?v=42 já está "resolvida" com o conteúdo antigo.

Mesmo problema, outro caminho: módulos ES sem versão

No mesmo site houve um caso parecido. Quando um módulo importa outro por uma URL sem versão, por exemplo:

import { scan } from './scanner.js';

o token da página não chega até ele. O ?v= do <script> de entrada vale só para aquele arquivo. O scanner.js continua com a mesma URL e, com cache de um ano, um visitante que já esteve no site não recebe a versão nova.

Tínhamos 9 módulos assim, entre eles 60 KB de lógica de varredura. A correção foi um bump de token em cascata: cada import passou a levar a versão, e mudar um módulo exige mudar o token em quem o importa, até chegar à página. Atualizar esses 9 módulos puxou 5 entry points.

Como evitar

O que a documentação recomenda e o que esse erro ensina:

  • Confirme o arquivo publicado antes de trocar o ?v=. Abra o asset pela URL do próprio deploy (o endereço de preview que o Pages gera) e verifique que o conteúdo já é o novo. Nunca teste com o token que você vai publicar: o próprio teste grava a chave no cache. Só então publique o HTML com o token novo.
  • Prefira hash no nome do arquivo. A web.dev sugere colocar fingerprint ou versão no nome do arquivo. app.3f9a1c.js não existe na origem antes do deploy, então não há versão antiga com esse nome para a CDN guardar por engano. Sem bundler, isso pede um passo no script de deploy que renomeie os arquivos e atualize as referências.
  • Purge não salva o navegador. O purge de arquivo único do Cloudflare limpa a cópia da CDN. A cópia immutable que já foi para o navegador de alguém continua lá até expirar (ou até um reload forçado daquela pessoa). Se isso já aconteceu, troque o token de novo, depois de confirmar o arquivo.
  • Versione os imports também. Se você usa módulos ES sem bundler, o token da página não protege os imports internos.

A regra

Com cache imutável, a ordem do deploy importa: o arquivo novo precisa estar no ar antes de existir uma URL que aponte para ele. Com max-age=31536000, immutable, uma janela curta no deploy vira um erro que fica até um ano no cache.

Carregando publicação patrocinada...
1

Conferir o arquivo no preview olha outro host, então essa checagem não grava a chave do domínio de produção. Quando o deploy assume, o primeiro GET com o ?v= novo ainda pode encontrar o arquivo antigo nesse edge e guardar essa resposta por um ano?