Mecanismos de caché en Next.js: por qué tu web no se actualiza
Le cambias un dato en la base de datos, refrescas la página y sigue mostrando lo viejo. No es un bug: son 3 capas de cache distintas trabajando en tu contra.
Cambias un precio en la base de datos, refrescas la página, y ahí sigue el precio viejo. Le das F5 tres veces más. Nada. Tu primer instinto es pensar que hay un bug en Next.js, o que tu deploy no se aplicó bien.
No hay ningún bug. Lo que estás viendo es el comportamiento esperado de un sistema con no una, sino tres capas de cache trabajando en simultáneo — y si no sabes cuál de las tres está reteniendo tu contenido viejo, vas a perder horas “arreglando” algo que en realidad nunca estuvo roto.
En este artículo vas a entender exactamente qué capa hace qué, con un experimento que puedes replicar en tu propio proyecto en menos de 5 minutos.
Las 3 capas de cache que compiten por tu contenido
App Router de Next.js no tiene “un cache”. Tiene tres, cada uno con un propósito distinto, y viven en lugares distintos:
| Capa | Qué guarda | Dónde vive | Cómo se invalida |
|---|---|---|---|
| Data Cache | El resultado de una función que trae datos (fetch, o unstable_cache) | Servidor, persiste entre requests y deploys | Por tiempo, o manualmente con revalidateTag/updateTag |
| Full Route Cache | El HTML/RSC ya renderizado de una ruta completa | Servidor (build time o ISR) | Se invalida en cascada cuando el Data Cache que usa cambia, o por revalidatePath |
| Router Cache | Payload de rutas ya visitadas por ese usuario | Memoria del navegador | Al navegar, con router.refresh(), o expira solo |
El problema real casi nunca es “el cache no funciona”. El problema es que asumes que invalidaste una capa cuando en realidad necesitabas invalidar otra.
Data Cache: el resultado de tu query, guardado
Si envuelves una función de acceso a datos con unstable_cache, el resultado de esa función queda guardado en el servidor, no solo en memoria del proceso — persiste incluso entre deploys, hasta que algo lo invalide explícitamente.
import { unstable_cache } from 'next/cache'
import { prisma } from '@/lib/prisma'
export const getProducts = unstable_cache(
async () => {
console.log('🔴 QUERY REAL a la base de datos')
return prisma.product.findMany({ orderBy: { createdAt: 'desc' } })
},
['products-list'],
{ tags: ['products'], revalidate: 3600 }
)
El console.log de ahí no es decorativo — es la forma más honesta de comprobar si tu cache está funcionando. Si lo ves en cada request, no se está cacheando nada. Si desaparece después de la primera visita, ya sabes que el Data Cache está haciendo su trabajo.
Full Route Cache: no solo el dato, la página entera
Aquí está el matiz que más confunde a la gente: cachear el dato no es lo mismo que cachear la página. Una ruta estática o con ISR guarda el HTML/RSC ya generado — si esa capa está sirviendo una versión vieja, ni siquiera va a intentar volver a ejecutar tu componente, mucho menos tu query.
// src/app/page.tsx
export const revalidate = 3600 // ISR: máximo 1h de vida para el HTML de esta ruta
export default async function HomePage() {
const products = await getProducts()
// ...
}
Con esta línea, aunque tu Data Cache se invalidara solo, la página seguiría sirviendo el HTML viejo hasta que se cumpla la hora — a menos que dispares una invalidación manual que también limpie el Full Route Cache (volveremos a esto).
Router Cache: la capa que vive en el navegador de tu usuario, no en tu servidor
Esta es la que menos control tienes desde el backend. Cuando un usuario navega entre rutas de tu sitio (usando <Link>, no un F5 completo), Next.js guarda esas rutas en memoria del cliente para que la navegación se sienta instantánea. Si acabas de invalidar cache en el servidor pero un usuario que ya tenía la ruta prefetcheada sigue navegando entre pestañas, puede seguir viendo la versión anterior hasta que esa entrada expire o el navegador se refresque.
El experimento: repruébalo tú mismo en 5 minutos
Toma cualquier página con unstable_cache + revalidate, y haz esto:
- Entra a la página. Mira tu terminal — deberías ver el log de la query real.
- Refresca con F5 varias veces seguidas.
- El log no vuelve a aparecer.
Eso confirma las dos primeras capas trabajando juntas: la primera visita ejecuta la query y guarda tanto el resultado (Data Cache) como el HTML (Full Route Cache). Las siguientes visitas ni siquiera llegan a ejecutar tu función — se sirve el HTML ya hecho, directo.
Ahora, actualiza el dato en tu base de datos por fuera de la app (directo en tu gestor de BD o con un script), y refresca de nuevo. Sigue sin cambiar. Eso no es un bug — es exactamente el comportamiento que le pediste con revalidate: 3600: tolerar hasta una hora de desactualización a cambio de no pagar el costo de una query en cada visita.
Por qué “limpiar cache” no es un botón, es una decisión de arquitectura
La pregunta que deberías hacerte no es “¿cómo borro el cache?”, sino “¿qué evento de negocio justifica que este dato específico deje de ser válido?”. Por ejemplo, en un catálogo de e-commerce:
- El stock cambió porque alguien compró → invalida el cache de ese producto y del listado, ya
- Pasó una hora desde la última visita → tolerable mostrar el dato viejo un momento más mientras se regenera en segundo plano
Estos dos casos usan mecanismos distintos, y mezclarlos es la causa más común de “cache que no funciona”:
// Invalidación por tiempo (ya la vimos arriba)
export const revalidate = 3600
// Invalidación por evento de negocio, en una Server Action
'use server'
import { updateTag } from 'next/cache'
export async function updateStock(productId: string, newStock: number) {
await prisma.product.update({ where: { id: productId }, data: { stock: newStock } })
updateTag('products') // limpia Data Cache Y Full Route Cache de todo lo etiquetado 'products'
}
El detalle clave: al usar el mismo tag: 'products' tanto en getProducts() como en la función que trae un producto individual, un solo updateTag('products') invalida ambas rutas de un solo golpe — no necesitas ir ruta por ruta.
Mejores prácticas para no perseguir cache fantasma
- Loguea tus queries reales durante desarrollo. Un
console.logtemporal dentro de la función cacheada te ahorra horas de sospechar del framework. - Decide el tag antes que el código. Pregúntate qué otras rutas comparten ese mismo dato, y usa el mismo tag en todas — así una sola invalidación las cubre todas.
- No cachees lo que cambia por usuario. Datos personales (carrito, sesión) nunca deben pasar por
unstable_cache— ese cache es compartido entre todos los usuarios del servidor. - Diferencia “por tiempo” de “por evento”. Si el dato cambia por una acción concreta (una compra, una edición), invalida por evento. Si solo se desactualiza con el paso del tiempo, usa
revalidate.
Conclusión
La próxima vez que tu página no refleje un cambio, antes de asumir que Next.js tiene un bug, pregúntate: ¿es el Data Cache, el Full Route Cache, o el Router Cache del navegador el que está reteniendo la versión vieja? Identificar la capa correcta es el 90% del trabajo — el resto es elegir la función de invalidación correcta, que es justo el tema de mi próximo artículo de esta serie.
¿Tu equipo está lidiando con problemas de cache o performance en una app Next.js en producción? Agenda una asesoría técnica gratuita de 15 minutos y lo revisamos juntos.
¿Tienes un proyecto en mente?
Convierte tu idea en un producto real
Desarrollo web, aplicaciones a medida y consultoría tecnológica para empresas y startups. Cuéntame tu proyecto y te respondo en menos de 24 horas.