wandres.dev
ASSETS · CSS, imágenes, fuentes

Hashing para cache-busting: el hash del contenido

El build emite archivos con nombres como name.hash.js, donde el hash es una huella del contenido, no un adorno. Ese direccionamiento por contenido es lo que permite servir los assets con caché eterna e inmutable sin arriesgarse jamás a entregar bytes obsoletos, y lo que explica la cascada de invalidación que renombra a los dependientes de un archivo cuando cambia.

⏱ 16 min

El build emite archivos con nombres como entry.4f2a9c.js o logo.8b1d3e.png. Ese hash no es decoración: es una huella del contenido, un resumen de los bytes del archivo. Y es, además, el mecanismo exacto que te permite cachear los assets para siempre sin arriesgarte nunca a servir una versión vieja. Entender por qué un hash de contenido convierte un problema difícil de sistemas distribuidos —saber si el cliente tiene la versión correcta— en una simple convención de nombres es el corazón de esta lección.

🎯 Al terminar esta lección sabrás
  • Entender el patrón [name].[hash].[ext] y qué bytes resume el hash.
  • Ver por qué un hash de contenido habilita la cabecera immutable y la caché eterna.
  • Configurar assetFileNames, chunkFileNames y entryFileNames con criterio.
  • Comprender la cascada de invalidación: por qué cambiar un archivo renombra a sus dependientes.

El nombre lleva la huella del contenido

El hash es un resumen criptográfico de los bytes del archivo. Mismo contenido, mismo hash, mismo nombre; cambia un solo byte y el hash cambia, y con él el nombre. El patrón por defecto en el build es [name].[hash].[ext], donde cada corchete es un token que Vite sustituye: [name] es el nombre base, [hash] la huella, [ext] o [extname] la extensión, y puedes acotar la longitud con [hash:8].

  • [name] — el nombre base del archivo, para que la salida siga siendo legible.
  • [hash] — la huella del contenido; el corazón del cache-busting.
  • [ext] y [extname] — la extensión, sin y con el punto inicial.
  • [hash:8] — la huella acotada a los primeros ocho caracteres.
dist/assets/
├─ index.4f2a9c1b.js      # entry: name = index
├─ vendor.a7b3e2f1.js     # chunk de dependencias estables
├─ hero.8b1d3e07.avif     # asset con su propia huella
└─ index.c19d4a55.css     # css extraido, tambien con hash

La consecuencia es que el nombre de cada archivo se vuelve una identidad intrínseca de su contenido exacto. No lo nombras por dónde vive ni por cómo lo llamas, sino por lo que es.

Conviene precisar qué bytes entran en el hash. Para un asset estático —una imagen, una fuente— es el contenido literal del archivo. Para un chunk de JavaScript o una hoja de CSS es el resultado final tras transpilar, minificar y reescribir imports, no tu código fuente: por eso dos fuentes distintas que minifican a lo mismo comparten hash, y un cambio que la minificación borra —un comentario, un espacio— puede no alterar el nombre en absoluto. El hash resume el artefacto emitido, que es lo único que el navegador llega a ver.

La longitud es un compromiso. Un hash corto da nombres legibles y una probabilidad ínfima —pero no nula— de colisión; uno largo la vuelve astronómicamente improbable a costa de nombres más ruidosos. Ocho caracteres hexadecimales bastan de sobra para un proyecto normal, porque el espacio que cubren es enorme frente al puñado de archivos que emites. Y el hash es determinista: el mismo contenido produce siempre el mismo nombre entre máquinas y entre builds, lo que hace tus artefactos reproducibles y comparables entre despliegues.

Por qué esto permite cachear para siempre

Si la URL identifica un contenido exacto e inmutable, puedes decirle al navegador que la guarde durante un año y que no vuelva a preguntar. Esa es la cabecera que sirves para los assets con hash.

Cache-Control: public, max-age=31536000, immutable

max-age=31536000 es un año en segundos; immutable le dice al navegador que ni siquiera revalide durante ese tiempo. Es seguro precisamente porque la URL está direccionada por contenido: si el archivo cambiara, cambiaría su nombre, y sería otra URL. Cuando editas tu código y reconstruyes, los nombres con hash cambian, el HTML pasa a apuntar a los nombres nuevos, y el navegador —al ver una URL que jamás ha pedido— descarga la versión fresca. El archivo viejo, sencillamente, no se vuelve a solicitar nunca.

Compáralo con la técnica que esto reemplaza. Durante años el cache-busting se hacía con una cadena de consulta manual —estilos.css?v=123— que había que recordar incrementar en cada cambio y que muchos proxies y CDN ignoraban al cachear. El hash de contenido automatiza esa disciplina y la vuelve infalible: no hay número de versión que olvidar subir, porque la versión es el contenido.

Hay un archivo que no debe llevar hash: el index.html. Es el punto de entrada estable que el usuario teclea o marca, así que se sirve con caché corta y siempre se revalida. El HTML es el puntero mutable que cambia para referenciar los nombres con hash nuevos: alguien tiene que sostener la referencia móvil, y ese alguien es el documento.

La estrategia de dos niveles

De aquí sale el patrón de cacheo canónico de una aplicación moderna, que reparte los archivos en dos niveles con políticas opuestas. Los assets con hash se sirven inmutables durante un año, porque su nombre garantiza que nunca mienten. El index.html se sirve sin caché o con una muy corta y siempre revalidada, porque es el único documento cuyo contenido cambia bajo un nombre fijo.

# El puntero mutable: siempre revalidado
Cache-Control: no-cache

# Todo lo demas: direccionado por contenido, inmutable un ano
Cache-Control: public, max-age=31536000, immutable

El navegador revalida solo un archivo pequeño en cada visita, descubre en él los nombres nuevos, y descarga únicamente los chunks que de verdad cambiaron. Es lo mejor de ambos mundos: frescura garantizada en el puntero, inmutabilidad total en todo lo demás.

Archivo Caché Por qué
assets con hash inmutable, un año el nombre es el contenido
index.html siempre revalidado puntero mutable de nombre fijo
ℹ️
Qué hace de verdad immutable

Sin immutable, incluso un archivo con max-age largo se revalida al recargar la página: el navegador manda una petición condicional preguntando “¿sigue vigente?”. Con immutable le dices que no pregunte siquiera durante todo el max-age. Esto solo es sensato bajo direccionamiento por contenido: como la URL es el checksum, una URL vieja no puede quedar obsoleta por definición. Sin hash en el nombre, immutable sería una bomba de relojería; con hash, es simplemente enunciar un hecho.

Configurar los nombres de salida

Vite deja los assets en assetsDir con un patrón por defecto sensato, pero puedes ajustarlo en rollupOptions.output. Hay tres claves paralelas: entryFileNames para los puntos de entrada, chunkFileNames para los chunks importados, y assetFileNames para los recursos no-JS.

import { defineConfig } from "vite";

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        entryFileNames: "assets/[name].[hash].js",
        chunkFileNames: "assets/[name].[hash].js",
        assetFileNames: "assets/[ext]/[name].[hash][extname]",
      },
    },
  },
});

Aquí, assetFileNames agrupa cada asset en una subcarpeta por extensión —assets/png/, assets/woff2/— manteniendo el hash. La forma de función de estas claves permite decidir el patrón archivo a archivo, útil cuando un subconjunto necesita otra ruta o convención. Cambies lo que cambies, no toques la parte del [hash]: es lo que sostiene toda la estrategia de caché.

Si un backend —Rails, Django, Laravel— sirve tu HTML, no puede adivinar los nombres con hash. Para eso está build.manifest, que emite un manifest.json mapeando cada entrada a su archivo final; el servidor lo lee e inyecta el script y el link correctos. Es el puente entre el hashing del bundler y una plantilla que vive fuera de él.

La cascada de invalidación

El hash se calcula sobre el contenido, y el contenido de un chunk incluye las URLs de lo que importa. Por eso, cuando una hoja del grafo cambia, su hash cambia; el módulo que la importaba pasa a contener una URL de import distinta, así que su propio contenido cambió, y su hash cambia también. El cambio asciende, eslabón a eslabón, hasta el entry. Es correcto y deseable —garantiza que nada quede desincronizado—, pero también explica por qué un retoque trivial puede invalidar más chunks de los que esperabas, y por qué aislar el código estable de vendedor con manualChunks alarga la vida de la caché.

De aquí sale una disciplina práctica: aísla en un chunk propio el código que rara vez cambia —tu framework, tus dependencias estables— para que el oleaje de tus cambios diarios no lo alcance. Si React vive en su propio chunk y no editas React, su hash no se mueve entre despliegues, y tus usuarios recurrentes lo sirven desde caché mientras solo descargan el chunk de aplicación que sí cambió. Sin ese aislamiento, cualquier retoque en tu código reempaqueta el framework junto a él y obliga a redescargarlo, desperdiciando la caché que el hashing te regalaba.

Tres hábitos mantienen la cascada a raya:

  • Aísla el framework y las dependencias estables en su propio chunk.
  • Deja tu código de aplicación, que cambia a diario, en chunks aparte.
  • Vigila el grafo: un import accidental de app hacia el vendedor rompe el aislamiento.
flowchart BT
A[la hoja util.ts cambia] --> B[su hash cambia]
B --> C[el import en app.ts apunta a otra url]
C --> D[el contenido de app.ts cambia]
D --> E[el hash de app.ts cambia]
E --> F[el entry apunta a otra url y su hash cambia]
style A fill:#f38ba8,color:#11111b
style F fill:#a6e3a1,color:#11111b
🔒

Hash inmutable

El nombre es la huella del contenido: misma URL, mismos bytes, para siempre. Base de la caché eterna.

📄

HTML sin hash

El puntero mutable que siempre revalidas y que apunta a los nombres nuevos tras cada build.

🌊

Cascada

Cambiar una hoja renombra a sus dependientes hasta el entry. Correcto, pero conviene acotarlo.

💡
Si el hash no se mueve, no es un fallo

Cambias un comentario, reconstruyes y el hash es idéntico. No es un error: la minificación borró el comentario antes de calcular el hash, así que el artefacto emitido es byte a byte el mismo. El cache-busting opera sobre lo que se sirve, no sobre lo que escribes, y esa es exactamente la propiedad que lo hace fiable.

El hash del nombre es almacenamiento direccionado por contenido

Poner un hash en el nombre de archivo es un caso concreto de una idea profunda y muy antigua: el content-addressed storage. En lugar de nombrar una cosa por dónde vive o por cómo la llamas —direccionamiento por ubicación—, la nombras por lo que es: un resumen de sus propios bytes. Git lo hace con sus blobs y trees; Nix con las rutas de su store; IPFS con sus CID; un bundler con [hash]. Las consecuencias son siempre las mismas y siempre poderosas. La identidad se vuelve intrínseca: dos archivos con el mismo contenido son automáticamente el mismo objeto, deduplicado gratis. La integridad se vuelve verificable: el nombre es el checksum. Y el cacheo se vuelve trivialmente correcto: una URL direccionada por contenido no puede quedar obsoleta, porque “obsoleto” significaría “el nombre apunta a un contenido distinto del de antes”, y eso es imposible por construcción —contenido distinto es un nombre distinto—. Por eso la cabecera immutable no es una optimización arriesgada sino la enunciación de un hecho. El compromiso que gestionas de verdad no es la corrección sino la granularidad: cuanto más finos hagas tus bundles, menos invalida un cambio pequeño, pero más peticiones y más cascada de hash incurres; cuanto más gruesos, menos archivos pero más ancho el oleaje de cada cambio. El cache-busting por hash de contenido convierte un problema duro de sistemas distribuidos —“¿cómo sé que el cliente tiene la versión correcta?”— en una convención de nombres que lo responde gratis. En cuanto ves el hash del archivo como direccionamiento por contenido y no como un sufijo aleatorio, entiendes por qué el HTML debe quedar sin hash —alguien tiene que sostener el puntero mutable—, por qué aislar el vendedor alarga la caché —aíslas lo que rara vez cambia— y por qué puedes ser valiente con una caché inmutable de un año. Lo que parecía un truco de despliegue resulta ser el mismo principio que sostiene los sistemas de versionado, los almacenes reproducibles y las redes de contenido distribuido: nombrar por lo que algo es, no por dónde está.

⚔️ Sigue la huella y la cascada
  1. Construye, anota el hash de un chunk, cambia un comentario en su fuente, reconstruye y confirma si el hash cambió o no —y explica por qué.
  2. Cambia un módulo hoja y observa la cascada: el hash de quien lo importa cambia también, hasta el entry.
  3. Sirve dist con un servidor estático e inspecciona la cabecera Cache-Control de un asset con hash frente a la del index.html.
  4. Configura assetFileNames para agrupar las imágenes en una subcarpeta y confirma la nueva distribución.
  5. Añade un manualChunks de vendedor, reconstruye dos veces cambiando solo el código de app, y verifica que el hash del vendedor no se mueve.