Cache busting: porque o teu ?v= provavelmente não faz nada

A nossa navegação apareceu sem qualquer estilo em produção durante boa parte de uma tarde. O HTML estava correto, o ficheiro CSS no servidor estava correto e o deploy tinha corrido sem erros. Foi mal diagnosticado duas vezes antes de alguém olhar para o sítio certo.

O app.css saía sem ?v= nenhum. A Cloudflare tinha-o em cache durante quatro horas e os browsers durante bem mais. Aquela versão renomeava um conjunto de classes CSS, por isso o HTML novo chegava a um browser que ainda guardava a folha de estilos do build anterior: nenhuma classe do markup correspondia a coisa alguma.

O que denunciava cache e não deploy é precisamente o que o tornava difícil de ver: um recarregamento forçado resolvia, e quem abria o site pela primeira vez via-o bem. Uma cache fria é indistinguível de um deploy que funciona, portanto ninguém a testar com um perfil limpo alguma vez reproduziu o problema.

O app.js até tinha ?v=, e isso era pior do que não ter. Interpolava a versão do package.json:

<script src="/js/app.js?v=<%= appVersion %>" defer></script>

Esse número não se mexe entre releases, e nós publicamos muito mais vezes do que o incrementamos. O JS levava um token que, para quem lesse o template, parecia cache busting e não invalidava nada. O mesmo bug do CSS, a uma release de disparar, com um engodo à frente.

Faz o hash do ficheiro

Um token de cache busting tem uma única função: mudar quando o ficheiro muda, e em mais nenhum momento. O único valor que faz isso deriva dos próprios bytes do ficheiro.

import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';

function assetVersion(relativePath) {
  const buf = readFileSync(join(__dirname, 'public', relativePath));
  return createHash('sha1').update(buf).digest('hex').slice(0, 8);
}

const cssVersion = assetVersion('css/app.css');

Lido uma vez no arranque: são saídas do build, não podem mudar debaixo de um processo em execução. Oito caracteres hexadecimais chegam e sobram, e SHA-1 serve aqui, porque estás a evitar uma colisão acidental entre duas versões do teu próprio ficheiro, não um atacante que escolhe o conteúdo.

E o template deixa de mentir:

<link rel="stylesheet" href="/css/app.css?v=<%= cssVersion %>">

Os três tokens a que se recorre em vez disto:

Um número de versão (?v=1.4.2) muda quando uma pessoa se lembra de o mudar, que não é o mesmo que quando o ficheiro muda. Era o nosso app.js.

Um timestamp ou um id de build muda de facto em cada deploy, logo nunca serve nada velho, mas também muda quando nada mudou. Cada deploy deita fora o CSS em cache de todos os visitantes e a cópia de cada nó do CDN, tenha-se mexido um byte ou não, e uma release que só tocou no backend custa na mesma um download a cada utilizador.

Um valor aleatório (?v=<%= Math.random() %>, e há quem escreva isto) dá um URL novo em cada carregamento de página. Não invalida a cache, desliga-a de vez para toda a gente.

Só o hash do conteúdo acerta nas duas metades: nunca serve velho e nunca volta a descarregar o que não mudou.

Query string ou nome do ficheiro

app.a1b2c3d4.css faz a mesma promessa com o hash no nome em vez da query string. Precisa de um passo de build que reescreva todas as referências, e é por isso que os projetos com bundler costumam ter isto e os renderizados no servidor não.

O argumento antigo a favor do nome é que algumas caches ignoram a query string ao calcular a chave, o que reduz o ?v= a decoração. Continua a ser uma definição que podes ligar sem querer, já que o nível de cache da Cloudflare tem uma opção «ignore query string», e com ela ativa todos os ?v= do teu site deixam de funcionar sem aviso. Verifica antes de contar com isso.

immutable é uma promessa sobre o URL, não sobre o ficheiro

Cache-Control: public, max-age=31536000, immutable diz ao browser para não revalidar, nem sequer quando o utilizador recarrega. Isso só é verdade se o URL for um endereço de conteúdo, e dito assim a regra escreve-se sozinha:

if (/\.(css|js)$/.test(name)) {
  const versioned = /[?&]v=/.test(url) || filePath.includes('/vendor/');
  res.setHeader('Cache-Control', versioned
    ? 'public, max-age=31536000, immutable'
    : 'public, max-age=3600');
}

O mesmo ficheiro pedido sem o token fica com uma hora. O token é a promessa, e nada deve ficar em cache um ano por uma promessa que ninguém fez.

Confirma que o teu middleware de estáticos não te sobrescreve isto. O @fastify/static envia max-age=0 por omissão; se definires cabeçalhos sem desativar o cacheControl dele, reconstrói o cabeçalho a partir desse valor e substitui o teu em silêncio. O cabeçalho continua lá, continua com ar de deliberado, e está errado.

As fontes marcamo-las immutable pelo nome. Um subset é substituído renomeando, não editando, portanto o nome já é o endereço do conteúdo. As bibliotecas vendorizadas na mesma: alpine-3.14.8.min.js leva a versão no nome e ninguém a edita no lugar.

As imagens levam uma semana, não um ano. Não vão versionadas, e um logótipo ou um favicon são mesmo substituídos no mesmo sítio: uma semana é tempo que conta e é curta o suficiente para corrigir um erro.

O ficheiro que tem de durar pouco

Servimos um script de badge que corre dentro de páginas de outras pessoas. Não controlamos o HTML delas nem o URL que incorporam. Um ano de immutable ali significa um bug que não conseguimos alcançar durante um ano: não há como invalidar, nem como pedir a cada site com a etiqueta que a mude. Leva uma hora, e é o correto mesmo que o ficheiro quase nunca mude.

Tudo o que viva num URL que não consegues versionar funciona assim. O tempo de cache é definido pela rapidez com que precisas de o poder corrigir, não pela frequência com que muda.

Verifica

O token do HTML corresponde ao ficheiro em disco?

curl -s https://oteusite.com/ | grep -o 'app\.css?v=[a-f0-9]*'
shasum -a 1 public/css/app.css | cut -c1-8

A mesma cadeia, ou o teu HTML aponta para algo que não descreve. Se o token ficar quieto ao longo de dois deploys que ambos mexeram no CSS, tens o nosso app.js.

Depois pergunta ao recurso o que responde:

curl -sI 'https://oteusite.com/css/app.css?v=a1b2c3d4' | grep -i 'cache-control\|last-modified\|cf-cache-status'

O last-modified é o que apanha um nó com a cópia velha. Se for anterior ao teu último deploy desse ficheiro, algo entre o teu servidor e o browser ainda guarda a versão antiga, e é o token do teu HTML que decide se isso te prejudica.

Analisa o teu site · Checklist de preparação para IA · O que medimos