Las cinco estrategias de caché y el árbol para elegir
Cache first, network first, stale while revalidate, network only y cache only: qué garantiza cada una, su implementación completa, el árbol de decisión por tipo de recurso, y las trampas del cuerpo de una respuesta.
Una estrategia de caché es la respuesta a una sola pregunta: cuando llega una petición, ¿a quién le pregunto primero y qué hago si falla? Hay cinco respuestas posibles y no hay una mejor, porque cada recurso tiene un compromiso distinto entre velocidad y frescura. Elegir bien es cuestión de clasificar recursos, no de elegir la estrategia de moda, y el error más caro es aplicar la misma a todo.
- Implementar las cinco estrategias con manejo correcto de errores y del cuerpo de la respuesta.
- Aplicar el árbol de decisión para asignar una estrategia a cada tipo de recurso.
- Evitar las cuatro trampas de la API de cachés que producen fallos silenciosos.
- Medir el efecto de cada estrategia en tiempo hasta el primer byte.
Las cinco estrategias
Todas comparten la misma forma: un manejador de fetch que llama a event.respondWith() con una promesa de Response. Lo que cambia es el orden de las consultas.
Cache first. Mira la caché; si hay algo, lo devuelve sin tocar la red. Latencia mínima y constante, y el contenido puede ser de hace un año. Es la única correcta para recursos con hash en el nombre, donde la URL identifica el contenido de forma unívoca y por tanto no hay nada que revalidar nunca.
async function cacheFirst(peticion, nombreCache) {
const cache = await caches.open(nombreCache);
const enCache = await cache.match(peticion);
if (enCache) return enCache;
const respuesta = await fetch(peticion);
// Solo se guardan respuestas correctas y del propio origen.
if (respuesta.ok && respuesta.type === 'basic') {
cache.put(peticion, respuesta.clone());
}
return respuesta;
}
Network first. Va a la red y guarda; si la red falla o tarda demasiado, tira de la caché. Máxima frescura con una red buena y funcionamiento sin conexión con la caché como red de seguridad. El detalle que casi todo el mundo olvida es el tiempo límite: sin él, con una red muy lenta pero no caída, la petición se queda colgada minutos y la caché no llega a usarse nunca. Esa es la situación real de un móvil con una barra de cobertura, y es peor que estar sin conexión.
async function networkFirst(peticion, nombreCache, limiteMs = 3000) {
const cache = await caches.open(nombreCache);
try {
const controlador = new AbortController();
const reloj = setTimeout(() => controlador.abort(), limiteMs);
const respuesta = await fetch(peticion, { signal: controlador.signal });
clearTimeout(reloj);
if (respuesta.ok) cache.put(peticion, respuesta.clone());
return respuesta;
} catch {
const enCache = await cache.match(peticion);
if (enCache) return enCache;
throw new Error('Sin red y sin copia en cache');
}
}
Stale while revalidate. Devuelve lo que hay en caché inmediatamente y lanza en paralelo una petición que actualizará la copia para la próxima vez. Es la misma idea que la directiva HTTP del mismo nombre, aplicada en el cliente. Latencia de caché con frescura de un ciclo de retraso. Es la mejor opción por defecto para casi todo lo que no es crítico y no tiene hash.
async function staleWhileRevalidate(peticion, nombreCache) {
const cache = await caches.open(nombreCache);
const enCache = await cache.match(peticion);
const enRed = fetch(peticion)
.then((respuesta) => {
if (respuesta.ok) cache.put(peticion, respuesta.clone());
return respuesta;
})
.catch(() => undefined);
// Si hay copia, se devuelve ya y la red actualiza por detras.
return enCache || (await enRed) || Response.error();
}
Network only. No cachea nada. Es la estrategia por defecto si no llamas a respondWith, y también la elección explícita para todo lo que no debe guardarse: peticiones de escritura, endpoints de autenticación, analítica.
Cache only. Solo caché, nunca red. Útil para recursos que se precargaron en install y de los que se sabe con certeza que están: el caparazón de la aplicación, la página de sin conexión, los iconos.
Hay una regla implícita en la primera implementación que merece atención: event.respondWith() es una decisión de todo o nada. Si lo llamas, tu código es responsable de producir una respuesta; si tu promesa rechaza, el navegador muestra un error de red y no vuelve a la red por su cuenta. Por eso la primera línea de defensa de un manejador de fetch es no llamar a respondWith cuando no sabes qué hacer:
self.addEventListener('fetch', (evento) => {
const url = new URL(evento.request.url);
// Todo lo que no sea GET del propio origen se deja pasar sin tocar.
if (evento.request.method !== 'GET') return;
if (url.origin !== self.location.origin) return;
evento.respondWith(enrutar(evento.request, url));
});
El árbol de decisión
Las preguntas, en orden. La primera es la única que ramifica de verdad.
¿El nombre del fichero contiene un hash de su contenido? Si sí, cache first con caducidad infinita, y punto. No hay más que decidir. Estos recursos son el grueso de los bytes de un sitio moderno y son el caso fácil.
¿Es una navegación? Es decir, request.mode === 'navigate'. network first con tiempo límite corto y la página de sin conexión como último recurso. Una navegación es lo único que el usuario percibe como “la página no carga”, así que aquí la frescura importa y el fallo tiene que ser elegante.
¿Es una respuesta de API? Depende de la tolerancia a contenido viejo. Si son datos que cambian por minutos y el usuario espera verlos al día, network first. Si son datos de referencia —un catálogo, una configuración, una lista de países—, stale while revalidate. Si es una escritura o algo sensible, network only.
¿Es contenido de terceros o multimedia grande? cache first con límite de número de entradas y caducidad. El límite no es opcional: sin él, una galería de imágenes llena la cuota del origen y el navegador empieza a expulsar cosas, incluida tu caché de aplicación.
| Recurso | Estrategia | Caducidad | Notas |
|---|---|---|---|
| JS y CSS con hash | cache first | infinita | Se limpian por nombre de caché al cambiar de versión |
| Navegaciones | network first | 1 día | Con límite de 3 s y página de sin conexión |
| API de datos volátiles | network first | 5 min | Devolver copia vieja con una marca de tiempo visible |
| API de datos de referencia | stale while revalidate | 1 día | Lo mejor para catálogos y configuración |
| Imágenes de contenido | cache first | 30 días | Con tope de entradas, 60 o 100 |
| Fuentes | cache first | 1 año | Pocas y grandes: candidato ideal |
| Escrituras y autenticación | network only | — | Nunca guardar |
| Caparazón y página de fallo | cache only | — | Precargadas en install |
El enrutador que junta todo esto es más corto de lo que parece:
const CACHES = {
estaticos: 'estaticos-v14',
documentos: 'documentos-v14',
datos: 'datos-v14',
imagenes: 'imagenes-v14',
};
async function enrutar(peticion, url) {
if (peticion.mode === 'navigate') {
try {
return await networkFirst(peticion, CACHES.documentos, 3000);
} catch {
return (await caches.match('/sin-conexion.html')) || Response.error();
}
}
if (/\.[0-9a-f]{8,}\.(js|css|woff2)$/.test(url.pathname)) {
return cacheFirst(peticion, CACHES.estaticos);
}
if (url.pathname.startsWith('/api/referencia/')) {
return staleWhileRevalidate(peticion, CACHES.datos);
}
if (url.pathname.startsWith('/api/')) {
return networkFirst(peticion, CACHES.datos, 5000);
}
if (peticion.destination === 'image') {
return cacheFirst(peticion, CACHES.imagenes).then(limitar(CACHES.imagenes, 60));
}
return fetch(peticion);
}
// Recorta la cache al numero de entradas indicado, las mas antiguas primero.
function limitar(nombre, maximo) {
return async (respuesta) => {
const cache = await caches.open(nombre);
const claves = await cache.keys();
for (let i = 0; i < claves.length - maximo; i++) {
await cache.delete(claves[i]);
}
return respuesta;
};
}
El recorte por número de entradas funciona porque cache.keys() devuelve las claves en el orden en que se insertaron, así que borrar desde el principio elimina las más antiguas. No hay caducidad por tiempo en la API: si la quieres, tienes que guardar la fecha tú, normalmente en el propio objeto Response con una cabecera añadida.
Las cuatro trampas
El cuerpo se consume una sola vez. Un Response tiene un flujo de datos que se agota al leerlo. Si lo pasas a cache.put() y luego lo devuelves, el segundo uso encuentra el flujo vacío. De ahí el .clone() en todas las implementaciones de arriba, y de ahí que el clonado tenga que hacerse antes de leer nada. Clonar después de haber empezado a consumir lanza una excepción.
Las respuestas opacas ocupan mucho más de lo que dicen. Una petición a otro origen sin CORS devuelve una respuesta de tipo opaque: no puedes leer su estado ni su cuerpo, y respuesta.ok es false aunque haya ido bien. Si la guardas, el navegador reserva una cantidad de cuota inflada —típicamente varios cientos de kilobytes por entrada, con independencia del tamaño real— porque no puede revelar el tamaño sin filtrar información entre orígenes. Guardar cien iconos opacos puede consumir decenas de megabytes de cuota. El remedio es pedir esos recursos con crossorigin y CORS, o no cachearlos.
Las peticiones de rango no las gestiona cache.match. El elemento de vídeo y el de audio piden fragmentos con la cabecera Range. La API de cachés no sabe responder a una petición de rango con un trozo de una entrada completa: devuelve la respuesta entera con estado 200, y el reproductor no sabe qué hacer con ella. Si cacheas multimedia, tienes que construir tú la respuesta parcial con estado 206 y las cabeceras Content-Range, o dejar el multimedia fuera del worker, que es lo razonable en casi todos los casos.
Una respuesta redirigida no sirve para una navegación. Si el manejador devuelve para una navegación una respuesta cuyo redirected es true, el navegador aborta con un error de seguridad. Ocurre al cachear una URL que redirige y servirla luego. La salida es reconstruir la respuesta:
async function seguraParaNavegacion(respuesta) {
if (!respuesta.redirected) return respuesta;
// Se reconstruye para limpiar la marca de redireccion.
const cuerpo = await respuesta.blob();
return new Response(cuerpo, {
status: respuesta.status,
statusText: respuesta.statusText,
headers: respuesta.headers,
});
}
Para verificar que todo esto surte efecto, la medida más directa es comparar el tiempo hasta el primer byte de las peticiones que pasan por el worker frente a las que no. En Resource Timing, una petición servida por el service worker tiene workerStart mayor que cero, y la diferencia entre workerStart y fetchStart es el arranque del worker, que en frío cuesta decenas de milisegundos y es justo el problema que resuelve la precarga de navegación.
performance.getEntriesByType('resource')
.filter((r) => r.workerStart > 0)
.forEach((r) => console.log(
new URL(r.name).pathname,
'arranque del worker', Math.round(r.fetchStart - r.workerStart),
'total', Math.round(r.responseEnd - r.startTime)
));
Todas las tablas de estrategias, incluida la de esta lección, se organizan por tipo de recurso, y eso está bien como atajo y mal como forma de pensar. El eje que de verdad decide no es qué clase de fichero es, sino qué experimenta el usuario si esa petición concreta devuelve algo viejo, y qué experimenta si no devuelve nada. Son dos preguntas, y sus respuestas dan cuatro cuadrantes que no coinciden con los tipos MIME. Un fichero de traducciones de hace una semana no lo nota nadie, pero si no llega, la interfaz aparece llena de claves internas y parece rota: contenido viejo inofensivo, ausencia catastrófica, luego caché agresiva sin dudarlo. Un saldo de cuenta de hace una semana es directamente peligroso, mientras que su ausencia se resuelve con un mensaje honesto: contenido viejo catastrófico, ausencia tolerable, luego red exclusivamente y un estado de error bien diseñado. Un listado de artículos tolera las dos cosas a medias, y ahí es donde stale while revalidate brilla, porque es literalmente la estrategia de “lo viejo vale un rato y la ausencia se disimula”. Y hay un cuarto cuadrante, el de las cosas en las que ni lo viejo ni la ausencia son aceptables, que es donde la gente pierde semanas buscando una estrategia mágica que no existe: si ninguna de las dos degradaciones es admisible, el problema no es de caché, es de producto, y hay que decidir cuál de las dos se acepta. Piensa además que las dos preguntas no las contesta un ingeniero solo. “¿Cuánto puede estar desactualizado el precio?” y “¿qué ve el usuario si esto no carga?” son preguntas de diseño y de negocio, y llevarlas a esa conversación con esos términos es la parte del trabajo que distingue una capa de caché que aguanta años de una que se desactiva en la primera incidencia. La consecuencia más práctica: antes de escribir el manejador de fetch, haz la tabla de tus rutas con dos columnas —qué pasa si es viejo, qué pasa si falta— y rellénala con producto delante. La estrategia de cada fila se deduce sola en cuanto las dos columnas están escritas, y lo que es más valioso, quedan escritas las decisiones para dentro de dos años, cuando nadie recuerde por qué el catálogo se sirve de una forma y el carrito de otra.
- Haz la tabla de dos columnas —qué pasa si es viejo, qué pasa si falta— para las diez rutas más pedidas de tu sitio.
- Implementa el enrutador con las cinco estrategias y verifica cada rama con el modo sin conexión de las herramientas del navegador.
- Comprueba que tu red de imágenes de terceros devuelve respuestas con CORS. Si son opacas, mide la cuota que consumen con
navigator.storage.estimate(). - Prueba
network firstcon la red limitada a la mitad de velocidad, no cortada. Comprueba que el tiempo límite entra en acción. - Mide
workerStartyfetchStarten un móvil real y anota cuánto cuesta el arranque en frío del worker.