Deduplicación de consultas ORM en una renderización de Next.js mediante React cache()
Aprenda por qué los componentes de servidor colocados pueden consultar el mismo registro varias veces por solicitud, cómo confirmarlo y cómo React cache() lo soluciona sin necesidad de recorrer propiedades.
Una ruta de Next.js puede parecer rápida en el navegador, mientras que su base de datos responde silenciosamente a la misma consulta tres o cuatro veces por cada visualización de página. El culpable rara vez es una consulta lenta; se trata de una búsqueda ordinaria que se repite más allá de los límites de renderizado que parecen independientes en su código: generateMetadata(), la página, una barra de navegación o un componente servidor anidado. Este artículo muestra cómo surge esa duplicación, cómo demostrar que realmente está ocurriendo y cómo eliminarla con cache() de React manteniendo el acceso a los datos cerca de los componentes que lo necesitan. También establece una distinción clara entre esta memorización por solicitud y el caché persistente, que responde a una pregunta completamente diferente.
Cómo una visualización de página se convierte en cuatro búsquedas
Tomemos una ruta de producto dinámico:
/products/[slug]
Varias partes de esa ruta necesitan el mismo producto. generateMetadata() requiere su nombre y descripción para la sección de encabezado del documento. La página necesita el registro completo. Los breadcrumbs necesitan la categoría. Un componente servidor anidado podría mostrar el precio o el estado de existencias. Cada uno de estos consumidores puede cargar razonablemente lo que necesita por sí mismo. La función de metadatos lo hace de la siguiente manera:
export async function generateMetadata({
params,
}: PageProps<'/products/[slug]'>) {
const { slug } = await params
const product = await getProduct(slug)
return {
title: product.name,
}
}
El componente de página hace lo mismo:
export default async function ProductPage({
params,
}: PageProps<'/products/[slug]'>) {
const { slug } = await params
const product = await getProduct(slug)
return <ProductDetails product={product} />
}
Y en algún lugar más profundo del árbol, otro componente servidor llama de forma independiente:
const product = await getProduct(slug)
Desde el punto de vista del diseño de componentes, esto es exactamente correcto. Cada elemento de la interfaz solicita sus datos donde los utiliza, y las responsabilidades quedan claras. El problema solo aparece al analizar el otro lado: los registros de consultas a la base de datos o las métricas de las API externas. Una sola solicitud puede generar varias búsquedas idénticas de productos. Se envía una única respuesta al navegador, pero su generación podría haber costado a la base de datos cuatro idas y venidas.
Lo que Next.js ya elimina por duplicado, y lo que no
Existe una distinción importante que hay que tener en cuenta antes de cambiar algo. Next.js memoriza automáticamente las solicitudes nativas idénticas de fetch realizadas durante la renderización del árbol de componentes React, y su documentación indica que esto se aplica a generateMetadata, layouts, páginas y componentes Server. Si getProduct() se basa en fetch, esas llamadas duplicadas ya podrían haberse consolidado en una sola.
Cuando los datos provienen de otra fuente que no sea fetch (un ORM, un controlador de base de datos, un SDK de terceros), no existe memorización automática. En ese caso, Next.js recomienda usar React cache() como forma de compartir tareas repetidas dentro de una misma solicitud.
La idea fundamental merece ser expresada claramente: el costo de rendimiento a menudo no proviene de una solicitud costosa, sino de una operación aparentemente barata que se repite a través de límites que el framework facilita para componer de forma independiente. La solución no consiste en trasladar todas las consultas a un único componente padre enorme, sino en dar a los accesos repetidos a los datos una identidad compartida única.
La colocación conjunta hace que el trabajo repetido sea invisible
Con los Componentes de Servidor, mantener el acceso a los datos junto a su consumidor es el enfoque natural. Un componente que muestra los detalles de un producto puede cargar el propio producto, y las pistas de navegación no necesitan recibir un objeto grande del producto que se haya transmitido a través de componentes no relacionados, solo porque un componente ancestro lo cargó primero.
Sin esa libertad, el intento habitual para evitar trabajos duplicados es cargar todo en la parte superior de la ruta y pasar los resultados hacia abajo a través de cada capa intermedia. Eso funciona, pero vincula componentes que no tienen una relación real. La alternativa de colocación conjunta consiste en que cada Componente Servidor dependa de una función de datos reutilizable:
const product = await getProduct(slug)
Eso mantiene cada requisito junto a su consumidor. La pregunta abierta es si esas llamadas comparten realmente una sola operación subyacente.
Si eventualmente realizan solicitudes nativas fetch idénticas, React las memoriza dentro del árbol de componentes. La documentación de Next.js cita exactamente esto como justificación para cargar los datos en el componente que los utiliza en lugar de en la parte superior de la ruta con propiedades transmitidas hacia abajo. Una llamada directa a un ORM como:
db.product.findUnique({
where: { slug },
})
No muestra ninguno de esos comportamientos solo porque dos componentes le pasan el mismo slug. Para React es simplemente una función asíncrona arbitraria, y se ejecutará cada vez que se llame hasta que se le proporcione una identidad memorizada.
Los límites de los componentes definen quién es el responsable de una parte de la interfaz de usuario. No dicen nada sobre quién se encarga del procesamiento repetido de datos. La colocación conjunta no es el error; asumir que existe colocación conjunta implica que hay deduplicación.
Mida antes de memorizar
La memorización es una respuesta a la duplicación observada, no a una sospecha. He aquí un ejemplo de llamada encontrada en cuatro archivos diferentes:
await getProduct(slug)
Esto no demuestra que la base de datos se consultó cuatro veces. En Next.js esa distinción es especialmente importante, ya que el framework puede estar eliminando automáticamente las llamadas fetch idénticas. Las versiones recientes de Next.js también ofrecen registro de desarrollo para las actividades fetch del lado servidor, lo que ayuda a ver qué se está solicitando realmente (consulte la documentación actual para saber cómo activarlo en su versión).
Si getProduct() se encuentra en un ORM, un controlador de base de datos o un SDK, instrumente esa capa en su lugar. Para una investigación local rápida, incluso un registro de tiempos básico es suficiente para revelar un patrón:
export async function getProduct(slug: string) {
console.time(`product:${slug}`)
const product = await db.product.findUnique({
where: { slug },
})
console.timeEnd(`product:${slug}`)
return product
}
Si observa que esa etiqueta se imprime cuatro veces por carga de página, eso es una evidencia. En entornos de producción se necesitan señales más claras: registros de consultas a la base de datos, seguimiento distribuido, spans APM, contadores de solicitudes ascendentes e IDs de solicitud que permitan correlacionar cada consulta con la vista de página que la generó.
Lo importante es distinguir entre dos declaraciones que suenan similares:
Function called four times
versus:
Underlying data source hit four times
No son equivalentes. Si la operación ya se ejecuta solo una vez, envolverla en otra capa no constituye una optimización; solo hace que la capa de datos sea más difícil de comprender. Optimice el trabajo repetido que realmente ha observado, no las llamadas a funciones repetidas que simplemente notó en el código.
Por qué una función respaldada por ORM escapa a la deduplicación
Supongamos que el cargador de productos es lo más simple posible:
export async function getProduct(slug: string) {
return db.product.findUnique({
where: { slug },
})
}
Imagínese ahora que se llama desde la función de metadatos, la página, las pistas de navegación y un componente de precios durante una sola solicitud. La función es la misma, el argumento es el mismo y la consulta también es la misma. Sin un límite de memorización, cada llamada sigue ejecutando su propia consulta contra la base de datos.
Tanto para ORM como para acceso directo a la base de datos, React cache() proporciona la memorización por solicitud que el fetch nativo obtiene de forma gratuita en el árbol de React. Next.js documenta exactamente este patrón para consultas directas a la base de datos, incluyendo el caso en que tanto generateMetadata como la página necesitan el mismo registro.
Esa comprensión también evita un mito común. Si getProduct() estuviera construido íntegramente a partir de llamadas idénticas a fetch, envolverlo en cache() únicamente para eliminar esas duplicadas no serviría de mucho, ya que Next.js ya las memoriza. Por lo tanto, la pauta no es aplicar cache() a cada cargador del lado servidor. Se trata de verificar si la forma en que se carga los datos ya está memorizada, y agregar una identidad compartida solo donde falte. Esa versión es mucho más difícil de aplicar ciegamente.
La solución: una función de datos memorizada
El cambio en el código en sí es pequeño. Aquí está el cargador antes:
export async function getProduct(slug: string) {
return db.product.findUnique({
where: { slug },
})
}
Y aquí está después de envolverlo con cache():
import { cache } from 'react'
import 'server-only'
export const getProduct = cache(async (slug: string) => {
return db.product.findUnique({
where: { slug },
})
})
Dos detalles merecen atención. La importación server-only hace que la compilación falle si este módulo llega a integrarse en código del cliente, lo cual es una medida adecuada para cualquier componente que se comunique con la base de datos. Además, cache() envuelve la función una sola vez, a nivel de módulo, de modo que todos los importadores reciban la misma función memorizada. Cada usuario sigue llamándola exactamente como antes:
const product = await getProduct(slug)
React almacena el resultado de cada argumento en su caché del lado servidor. Cualquier llamada posterior durante esa misma solicitud, a través de ese mismo envoltorio y con un argumento igual, recibe nuevamente el resultado almacenado, que en realidad es la misma promesa; por lo tanto, las llamadas concurrentes esperan una sola consulta. React descarta estos resultados memorizados entre solicitudes al servidor.
Qué no tuvo que cambiar
La parte valiosa de esta solución es todo lo que permaneció igual. El encabezado del producto sigue indicando sus propios requisitos de datos. Los breadcrumbs no adquieren nuevas propiedades. La generación de metadatos y la renderización de páginas siguen solicitando el producto de forma independiente, y ningún componente de interfaz se ve forzado a tener una relación de propiedad que no tenga naturalmente. La optimización ocurre exclusivamente en el nivel de los datos. Lo que se centralizó no es dónde se consumen los datos, sino la identidad de la operación que los genera.
Riesgos con cache()
Algunos detalles de implementación determinan si la memorización funciona realmente:
- Compartir una función memorizada. Envolver un cargador con
cache()en dos lugares diferentes genera dos funciones memorizadas independientes, cada una con su propio almacenamiento, un comportamiento que React documenta explícitamente. Defina el wrapper una vez en su módulo de acceso a datos e impórtelo en todas partes. - Preferir argumentos primitivos. React compara los argumentos por identidad, por lo que una cadena de texto funciona de manera fiable en el caché, mientras que un objeto creado recientemente como
{ slug }en cada llamada fallará siempre. - Tener en cuenta el ámbito.
cache()está diseñado para la renderización en servidor; fuera de una solicitud, como en un componente del cliente, no ofrece esta capacidad de deduplicación.
¿Por qué no simplemente obtener todo en la página?
La alternativa obvia es cargar el producto una vez en la parte superior y pasarlo hacia abajo:
export default async function ProductPage({
params,
}: PageProps<'/products/[slug]'>) {
const { slug } = await params
const product = await getProduct(slug)
return (
<>
<Breadcrumbs product={product} />
<ProductHeader product={product} />
<ProductDetails product={product} />
</>
)
}
Cuando la página realmente posee todo el objeto del producto, se trata de un diseño perfectamente válido. Los problemas comienzan cuando se traslada toda dependencia de datos únicamente para evitar trabajo duplicado en el backend. Con el tiempo, las propiedades se multiplican, los componentes intermedios comienzan a reenviar datos que nunca utilizan, y cada nuevo hijo que necesita un campo obliga a realizar cambios en toda la cadena. Los límites entre componentes terminan reflejando mecanismos de optimización en lugar de una verdadera propiedad.
Se recomiendan cambios en la memorización de solicitudes que permitan este equilibrio. La guía actual de Next.js indica que las llamadas idénticas a fetch pueden permanecer en los componentes que las necesitan, en lugar de requerir carga a nivel superior o exploración profunda de propiedades; además, para el acceso directo a la base de datos, cache() ofrece una función compartida en el servidor con semánticas de deduplicación similares. De esta manera se obtienen ambas características al mismo tiempo:
data close to consumer
+
deduplicated underlying work
Eliminar tareas repetidas no debería obligar a componentes no relacionados a compartir la propiedad de un mismo objeto de datos. El uso de hoisting sigue siendo una buena opción cuando el componente padre es naturalmente el propietario de los datos; simplemente no debería ser obligatorio, ya que la capa de datos carece de una identidad para las tareas repetidas.
La memorización de solicitudes no es un caché persistente
La terminología de Next.js puede hacer que esto se confunda fácilmente, por lo que vale la pena ser preciso. En este patrón, cache() de React no convierte la búsqueda de un producto por parte de un visitante en una respuesta almacenada para futuros visitantes. React elimina los resultados memorizados del servidor para cada solicitud. Dentro de una misma solicitud, las llamadas repetidas reutilizan el resultado:
getProduct("keyboard")
getProduct("keyboard")
getProduct("keyboard")
//reuse the memoized result
Una solicitud posterior comienza con un caché vacío y realiza la búsqueda nuevamente:
New request
getProduct("keyboard")
//perform the lookup again
Eso es memorización de solicitudes, y nada más. Reutilizar resultados entre solicitudes es una decisión arquitectónica separada. En el Next.js actual, los componentes Cache proporcionan la directiva use cache para almacenar en caché resultados más allá de una sola solicitud, donde cacheLife() controla cuánto tiempo permanece un elemento en caché y cacheTag() permite la invalidación por etiquetas. La guía sobre use cache y la revalidación basada en etiquetas aborda este tema en profundidad.
- Memorización de solicitudes: ¿debe ejecutarse una operación idéntica cuatro veces mientras se genera una respuesta?
- Caché persistente: ¿puede una solicitud posterior reutilizar una respuesta que ya se calculó anteriormente?
Solo el segundo factor aporta frescura e invalidación. Trátalos como dos decisiones separadas y la lógica de caché de Next.js resultará mucho menos confusa.
De dónde provienen realmente los ahorros
La consulta al producto en sí puede estar perfectamente bien. Supongamos que los informes de telemetría muestran cuatro operaciones idénticas en la base de datos por solicitud y, tras el cambio, solo queda una. Se han ahorrado tres búsquedas por visualización de página, lo cual parece trivial. Ahora multiplíquelo por el tráfico: una ruta que recibe miles de visitas evita tres veces esa cantidad de consultas, y una ruta muy concurrida en un día evita aún más. La duplicación menor en una ruta con alto tráfico puede sumar miles de consultas innecesarias a la base de datos o llamadas externas, sin que ninguna operación en particular parezca alarmante.
Esa es también la razón por la cual las cifras concretas de ahorro solo deben incluirse en una reclamación cuando provienen de sus propias mediciones. Si sus métricas de producción muestran un número específico de operaciones evitadas, cite ese número; sin telemetría, la expresión “se pueden ahorrar miles” describe con honestidad el efecto multiplicador sin inventar un estudio de caso.
También cambia la forma en que aborda el trabajo de mejora del rendimiento. La pregunta habitual es:
Which query takes 800 ms?
A menudo, la pregunta más valiosa es:
Why are we paying for this normal query
four times within one request?
Una búsqueda puede ser económica individualmente pero generar desperdicio en conjunto. El trabajo de mejora del rendimiento no se trata solo de hacer que cada operación sea más rápida; a veces consiste en eliminar operaciones que nunca deberían haber existido, y esto es especialmente importante cuando una pequeña ineficiencia se encuentra en un camino crítico.
Elegir el límite adecuado para la reutilización
Es relativamente fácil comprender cómo funciona la solicitud de memorización, ya que React nunca transfiere un resultado memorizado a una solicitud futura. El caché persistente requiere un análisis más detallado sobre su corrección. Con los componentes de caché, el trabajo almacenado en caché puede tener vidas útiles y etiquetas explícitas para su revalidación, precisamente porque el reuso entre solicitudes plantea dudas sobre cuánto tiempo permanece válido un valor y qué eventos deberían hacer que se vuelva obsoleto.
Por lo tanto, la mejor pregunta no es:
Can we cache this?
sino:
Across which boundary is reuse correct?
Una forma aproximada de pensar en los límites posibles:
- Dentro de una sola solicitud: seguro para casi cualquier lectura, incluidos los datos por usuario, ya que nada sobrevive a la respuesta. Aquí es donde opera
cache(). - Entre solicitudes, para datos públicos: adecuado para contenido que es el mismo para todos los visitantes, siempre y cuando se defina una vida útil y un método de invalidación.
Estos dos últimos puntos no son específicos de Next.js; se aplican a cualquier caché. En cuanto el resultado varía según quien lo solicite o qué pueda ver, la clave que determina la reutilización debe codificar esa misma distinción. Una tasa de acierto impresionante no significa nada si una respuesta puede filtrarse a una solicitud que nunca tuvo derecho a ella. La mejor caché es aquella cuyas reglas de reutilización coinciden con las reglas de corrección de los datos.
Las solicitudes representaban un problema de límites
Mire nuevamente la llamada que inició todo esto:
await getProduct(slug)
Nada estaba mal dentro de generateMetadata(), ni en la página, ni en un componente servidor anidado. Cada consumidor realmente necesitaba el producto. El desperdicio apareció únicamente porque esas llamadas razonables cruzaban por separado el mismo límite del backend. La solución no requería acelerar ninguna consulta; bastó con darse cuenta de que una misma respuesta se estaba obteniendo varias veces por renderizado. En código, toda la solución puede reducirse a un único contenedor, definido una vez en el módulo de datos y compartido por todos los consumidores:
cache(async (...) => ...)
Conclusiones clave
- Las llamadas nativas idénticas a
fetchya están memorizadas en el árbol de React por Next.js. Las llamadas a ORM, controladores y SDK no lo están, ycache()les proporciona una identidad memorizada por solicitud. - Mida primero. Una función llamada cuatro veces no es lo mismo que un acceso a una fuente de datos realizado cuatro veces.
cache() separados no comparten resultados.La lección más amplia: muchos de los mejoras significativas en el rendimiento de Next.js provienen menos de dónde se cargan los datos y más de decidir por cuánto tiempo un acceso a datos debe considerarse como una sola operación.