En aplicaciones web de alto rendimiento, la latencia de la base de datos y de los microservicios externos determina directamente el TTFB y la experiencia de usuario. La ejecución descontrolada de consultas SQL reiteradas y peticiones HTTP agota los recursos de cómputo y satura los pools de conexiones.
El App Router de Next.js incorpora un sistema de caché de múltiples niveles que combina las capacidades del núcleo de React con el runtime de Next.js. Este sistema abarca todo el ciclo de vida del dato: desde la deduplicación en memoria RAM durante un único renderizado de servidor, hasta la persistencia distribuida en CDN de páginas estáticas y el almacenamiento en caché de segmentos de ruta en el navegador del cliente.
Comprender en profundidad cada capa de caché, sus fronteras de desoptimización y los mecanismos de invalidación bajo demanda distingue al desarrollador que sufre por datos obsoletos en producción del arquitecto que diseña sistemas distribuidos consistentes y de alta velocidad.
Next.js opera 4 niveles de caché independientes, cada uno con un cometido especializado, un alcance aislado y un ciclo de vida propio:
Interactúa con el siguiente simulador para observar el tránsito de una petición a través de los 4 niveles de caché:
Inspeccione el flujo de peticiones a través de Router Cache, Full Route Cache, Memoization y Data Cache
GET /catalog HTTP/1.1 (Sin caché previa)El usuario entra por primera vez con la caché vacía. La petición consulta Full Route Cache y Data Cache en el servidor.
Al navegar por un enlace, el navegador verifica la caché de segmentos en la memoria RAM de la pestaña.
En Next.js 15+, las rutas dinámicas tienen un staleTime de 0s (se solicita de inmediato al servidor). Las estáticas retienen 5 minutos.
El servidor comprueba la existencia del HTML estático precompilado y del RSC Flight Payload.
Ante un acierto (Hit), se entrega el recurso preconstruido desde CDN o servidor sin ejecutar el código de los componentes.
Si la ruta es dinámica, React renderiza el árbol de Server Components. Las llamadas fetch() idénticas se deduplican en memoria.
Cada fetch individual valida la existencia de una respuesta almacenada en disco o en un CacheHandler distribuido.
Si el TTL está vigente, se devuelven los datos cacheados. Si caducó, se entrega el dato stale y se revalida en segundo plano.
Ante un fallo de caché (miss), la consulta accede a la base de datos o API externa. La respuesta se registra en la Data Cache.
React deduplica automáticamente las llamadas fetch con idéntica URL y las mismas opciones dentro del árbol de componentes de un mismo renderizado de servidor. La vida útil de esta memoria se limita a los milisegundos que dura la emisión de la respuesta.
[!IMPORTANT] Request Memoization no se comparte entre usuarios ni peticiones distintas. Es una optimización interna de un único ciclo de render de React Server Components. No existe una bandera global para apagarla, pero puede omitirse por llamada asignando un
AbortControllerexclusivo (mediante la propiedadsignal) afetch.
// app/components/Header.tsx
export async function Header() {
// Ejecuta la petición de red física real
const user = await fetch('https://api.internal/user/me').then(r => r.json());
return <header>Usuario: {user.name}</header>;
}
// app/components/Sidebar.tsx
export async function Sidebar() {
// Se obtiene al instante de Request Memoization (RAM) sin emitir otra petición HTTP
const user = await fetch('https://api.internal/user/me').then(r => r.json());
return <aside>Rol: {user.role}</aside>;
}En Next.js 15+, la llamada fetch estándar es sin caché por defecto (cache: 'no-store'). El almacenamiento en la Data Cache requiere configuración explícita.
// 1. Predeterminado en Next.js 15+: no-store (siempre obtiene datos frescos de la red)
const freshData = await fetch('https://api.internal/rates');
// 2. Caché persistente (se almacena indefinidamente hasta revalidación explícita)
const immutableData = await fetch('https://api.internal/dictionary', {
cache: 'force-cache',
});
// 3. Time-based revalidation (Stale-While-Revalidate durante 60 segundos)
const catalog = await fetch('https://api.internal/products', {
next: { revalidate: 60 },
});
// 4. Etiquetado de consultas para invalidación On-demand quirúrgica
const profile = await fetch('https://api.internal/user/10', {
next: { tags: ['user-10', 'users'] },
});Para purgar datos obsoletos ante mutaciones (Server Actions, Webhooks), se utilizan las utilidades del módulo next/cache. Estas funciones invalidan de forma simultánea los registros en la Data Cache y los segmentos dependientes en la Full Route Cache.
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { db } from '@/lib/db';
import { products } from '@/lib/schema';
import { eq } from 'drizzle-orm';
export async function updateProductPrice(productId: string, newPrice: number): Promise<{ success: boolean }> {
// 1. Mutar el registro en la base de datos principal
await db.update(products).set({ price: newPrice }).where(eq(products.id, productId));
// 2. Purga por etiqueta (invalida todas las consultas con este tag en toda la aplicación)
revalidateTag(`product-${productId}`);
revalidateTag('catalog-list');
// 3. Invalidar la ruta específica
revalidatePath(`/products/${productId}`, 'page');
return { success: true };
}Un cliente ejecuta una Server Action que realiza una mutación en la base de datos y llama a revalidateTag('catalog'). ¿De qué forma adquiere el navegador la interfaz actualizada?
Al configurar la opción next: { revalidate: 60 }, el framework aplica el siguiente protocolo:
Las consultas directas a base de datos (Prisma, Drizzle, pg) no emplean fetch global y quedan fuera de la Data Cache automáticamente. Para preservarlas se recurre a unstable_cache o a la directiva 'use cache' (Next.js 15.1+ con la bandera dynamicIO).
import 'server-only';
import { unstable_cache } from 'next/cache';
import { db } from '@/lib/db';
import { projects } from '@/lib/schema';
import { eq } from 'drizzle-orm';
export interface ProjectDTO {
id: string;
name: string;
stars: number;
}
export const getCachedProject = (projectId: string) => {
return unstable_cache(
async (id: string): Promise<ProjectDTO | null> => {
const record = await db.query.projects.findFirst({
where: eq(projects.id, id),
});
return record ?? null;
},
['project-detail-key', projectId], // Claves identificadoras
{
revalidate: 3600, // 1 hora
tags: [`project-${projectId}`, 'projects'],
}
)(projectId);
};Una ruta pasa de generación estática (Static Generation) a renderizado dinámico por petición (Dynamic Rendering) si invoca APIs dependientes del contexto de la petición:
import { cookies, headers } from 'next/headers';
// Desactivar de forma forzada la Full Route Cache en todo el segmento
export const dynamic = 'force-dynamic';
export default async function AnalyticsPage() {
// El acceso asíncrono a cabeceras y cookies en Next.js 15+ marca la ruta como estrictamente dinámica
const headersList = await headers();
const userAgent = headersList.get('user-agent') ?? 'unknown';
const cookieStore = await cookies();
const sessionId = cookieStore.get('sid')?.value;
return (
<main className="p-6">
<h1>Analítica del sistema</h1>
<p>Cliente: {userAgent}</p>
<p>Sesión: {sessionId}</p>
</main>
);
}Una aplicación Next.js opera con 4 pods (réplicas) tras un balanceador Nginx. El administrador actualiza el precio de un producto invocando revalidateTag('products'). ¿Por qué parte de los usuarios sigue viendo el precio anterior?
Por defecto, Next.js almacena tanto la Data Cache como la Full Route Cache en el sistema de archivos local del contenedor bajo la ruta .next/cache/.
Si la aplicación corre en un clúster escalado horizontalmente con balanceador de carga, la llamada a revalidateTag('products') en la réplica 1 limpiará la caché únicamente en la réplica 1. Las réplicas 2, 3 y 4 continuarán despachando registros obsoletos, provocando parpadeo de datos para los usuarios.
Solución: En entornos de producción con escalado horizontal, implementa un CacheHandler personalizado (basado en Redis o almacenamiento KV) y vincúlalo en next.config.ts mediante la directiva cacheHandler.
Muchos desarrolladores utilizan la opción 'layout' para forzar la actualización de un módulo completo: revalidatePath('/dashboard', 'layout').
Esto ocasiona la purga fulminante de todas las páginas subordinadas, slots paralelos y subsegmentos cobijados por ese layout. En portales con cientos de páginas, esto desencadena una tormenta de consultas simultáneas a la base de datos («Thundering Herd»), anulando las bondades de ISR.
Solución: Prioriza siempre la invalidación quirúrgica mediante etiquetas (revalidateTag), o invalida páginas concretas omitiendo la opción 'layout'.
La función unstable_cache aísla el cálculo del contexto de la petición HTTP en curso. Si dentro del callback envuelto intentas invocar cookies(), headers() o manipular referencias no serializables, Next.js arrojará una excepción DynamicServerError.
Solución: Suministra los valores dinámicos como parámetros explícitos incluidos en la clave compuesta de la caché.
// ❌ ERROR: Intentar leer cookies dentro de una función en caché
const getAuthData = unstable_cache(
async () => {
const cookieStore = await cookies(); // ¡Lanzará excepción DynamicServerError!
return fetchUserData(cookieStore.get('token')?.value);
},
['auth-user-key']
);
// ✅ CORRECTO: Pasa los datos dinámicos como parámetros explícitos incluidos en la clave
const getAuthData = (token: string) =>
unstable_cache(
async (authToken: string) => fetchUserData(authToken),
['auth-user-key', token]
)(token);Al utilizar unstable_cache para encapsular consultas a bases de datos, omitir el parámetro dinámico dentro del array de claves causa que todos los clientes reciban los datos computados para el primer usuario que invocó la función.
Solución: Incorpora siempre los identificadores de entidad en la matriz de claves compuestas de la caché.
// ❌ Error: La clave es estática, por lo que todos los clientes verán los datos del primer ID consultado
const getUser = unstable_cache(
async (id: string) => db.query.users.findFirst(eq(users.id, id)),
['user']
);
// ✅ Correcto: El ID está integrado en la clave compuesta de la caché
const getUser = unstable_cache(
async (id: string) => db.query.users.findFirst(eq(users.id, id)),
['user', id]
);