Cache busting: por qué tu ?v= seguramente no hace nada

Nuestra navegación se vio sin ningún estilo en producción durante media tarde. El HTML estaba bien, el fichero CSS del servidor estaba bien y el despliegue había terminado sin errores. Lo diagnosticamos mal dos veces antes de que alguien mirara donde tocaba.

app.css salía sin ?v= ninguno. Cloudflare lo tenía cacheado cuatro horas y los navegadores bastante más. Aquella versión renombraba un montón de clases CSS, así que el HTML nuevo llegaba a un navegador que seguía guardando la hoja de estilos de la anterior y ninguna clase del marcado casaba con nada.

Lo que delataba que era caché y no despliegue es justo lo que costaba ver: recargando con Ctrl+F5 se arreglaba, y quien abría la web por primera vez la veía perfecta. Una caché fría es indistinguible de un despliegue correcto, o sea que nadie que probara con un perfil limpio lo reprodujo jamás.

app.js sí llevaba ?v=, y eso era peor que no llevarlo. Interpolaba la versión del package.json:

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

Ese número no se mueve entre releases, y publicamos mucho más a menudo de lo que lo subimos. El JS llevaba un token que a cualquiera que leyera la plantilla le parecía cache busting y no invalidaba nada. El mismo fallo que el CSS, a una release de dispararse, con un señuelo delante.

Haz el hash del fichero

Un token de cache busting tiene un único trabajo: cambiar cuando cambia el fichero, y no en otro momento. El único valor que cumple eso sale de los propios bytes del fichero.

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');

Se lee una vez al arrancar: son salidas del build y no pueden cambiar bajo un proceso en marcha. Con ocho caracteres hexadecimales sobra, y SHA-1 aquí vale, porque lo que evitas es una colisión accidental entre dos versiones de tu propio fichero, no a un atacante que elige el contenido.

Y así la plantilla deja de mentir:

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

Los tres tokens que se usan en su lugar:

Un número de versión (?v=1.4.2) cambia cuando una persona se acuerda de cambiarlo, que no es lo mismo que cuando cambia el fichero. Eso era nuestro app.js.

Una marca de tiempo o un id de build sí cambia en cada despliegue, así que nunca sirve algo caducado, pero también cambia cuando no ha cambiado nada. Cada despliegue tira el CSS cacheado de todos tus visitantes y la copia de cada nodo del CDN aunque no se haya movido un byte, con lo que una release que solo tocó el backend le cuesta una descarga a cada usuario.

Un valor aleatorio (?v=<%= Math.random() %>, que hay gente que lo escribe) es una URL nueva en cada carga de página. Eso no invalida la caché, la apaga para siempre y para todo el mundo.

Solo el hash del contenido acierta por los dos lados: nunca sirve algo viejo y nunca vuelve a descargar lo que no ha cambiado.

Query string o nombre de fichero

app.a1b2c3d4.css hace la misma promesa poniendo el hash en el nombre en vez de en la query. Necesita un paso de build que reescriba todas las referencias, y por eso lo normal es tenerlo en proyectos con bundler y no tenerlo en los renderizados en servidor.

El argumento clásico a favor del nombre es que algunas cachés ignoran la query string al calcular la clave, y entonces el ?v= queda de adorno. Sigue siendo una opción que puedes activar sin querer, porque el nivel de caché de Cloudflare tiene un «ignore query string», y con eso puesto todos los ?v= de tu web dejan de funcionar sin avisar. Compruébalo antes de fiarte.

immutable es una promesa sobre la URL, no sobre el fichero

Cache-Control: public, max-age=31536000, immutable le dice al navegador que no revalide ni aunque el usuario recargue. Eso solo es cierto si la URL es una dirección de contenido, y dicho así la regla se escribe sola:

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');
}

El mismo fichero pedido sin el token se queda en una hora. El token es la promesa, y nada debería cachearse un año por una promesa que nadie ha hecho.

Comprueba que tu middleware de estáticos no te lo pisa. @fastify/static manda max-age=0 por defecto; si pones cabeceras sin desactivar su cacheControl, reconstruye la cabecera con ese valor y machaca la tuya en silencio. Ahí sigue la cabecera, con toda la pinta de ser deliberada, y mal.

Las tipografías las marcamos immutable por el nombre. Un subset se sustituye renombrando, no editando, así que el nombre ya es la dirección del contenido. Las librerías vendorizadas igual: alpine-3.14.8.min.js lleva su versión en el nombre y nadie la edita ahí dentro.

Las imágenes tienen una semana, no un año. No van versionadas, y un logo o un favicon sí se reemplazan en el sitio. Una semana da para algo y es corta para arreglar un despiste.

El fichero que tiene que caducar pronto

Servimos un script de badge que corre dentro de páginas ajenas. No controlamos ni su HTML ni la URL que incrustan. Un año de immutable ahí significa un bug que no puedes tocar en un año: no hay forma de invalidar nada ni de pedirle a cada web que lleva la etiqueta que la cambie. Le damos una hora, y es lo correcto aunque el fichero casi nunca cambie.

Todo lo que viva en una URL que no puedes versionar funciona así. Su vida en caché la marca lo rápido que necesitas poder arreglarlo, no lo a menudo que cambia.

Compruébalo

¿Coincide el token del HTML con el fichero que hay en disco?

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

La misma cadena, o tu HTML apunta a algo que no describe. Si el token no se mueve entre dos despliegues que tocaron el CSS, tienes nuestro app.js.

Después pregúntale al recurso qué contesta:

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

last-modified es lo que pilla un nodo caducado. Si es anterior a tu último despliegue de ese fichero, algo entre tu servidor y el navegador sigue guardando la copia vieja, y el token de tu HTML decide si eso te afecta o no.

Analiza tu web · Checklist de preparación para IA · Qué medimos