Rendimiento en Obtención de Datos — Elimina cascadas, paraleliza capturas y cachea agresivamente
Receta
// Server Component — sin JavaScript cliente para obtención de datos
// app/dashboard/page.tsx
import { db } from "@/lib/db";
export default async function DashboardPage() {
// Capturas PARALELAS — tiempo total = max(50ms, 120ms, 80ms) = 120ms
const [stats, orders, activity] = await Promise.all([
db.stats.findFirst(), // 50ms
db.order.findMany({ take: 10 }),// 120ms
db.activity.findMany({ take: 5 }), // 80ms
]);
return (
<div>
<StatsPanel data={stats} />
<OrderTable data={orders} />
<ActivityFeed data={activity} />
</div>
);
}
// Comparar con CASCADA — tiempo total = 50 + 120 + 80 = 250ms
// const stats = await db.stats.findFirst();
// const orders = await db.order.findMany({ take: 10 });
// const activity = await db.activity.findMany({ take: 5 });Cuándo usarlo: Para cada página que obtiene datos. Por defecto usa obtención de datos desde Server Component con Promise.all para solicitudes paralelas. Solo usa obtención del lado cliente (SWR, TanStack Query) cuando necesites actualizaciones en tiempo real o datos específicos del usuario que cambian después de la carga de página.
Ejemplo Funcional
// ---- ANTES: Capturas en cascada — 1.8s total, 5 solicitudes secuenciales ----
// app/products/[id]/page.tsx
"use client";
import { useState, useEffect } from "react";
export default function ProductPage({ params }: { params: { id: string } }) {
const [product, setProduct] = useState(null);
const [reviews, setReviews] = useState([]);
const [related, setRelated] = useState([]);
const [seller, setSeller] = useState(null);
const [inventory, setInventory] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function load() {
// CASCADA: cada captura espera por la anterior
const productRes = await fetch(`/api/products/${params.id}`); // 200ms
const productData = await productRes.json();
setProduct(productData);
// Estas dependen del producto pero se obtienen secuencialmente
const reviewsRes = await fetch(`/api/reviews?productId=${params.id}`); // 300ms
setReviews(await reviewsRes.json());
const relatedRes = await fetch(`/api/products/related?category=${productData.category}`); // 400ms
setRelated(await relatedRes.json());
const sellerRes = await fetch(`/api/sellers/${productData.sellerId}`); // 150ms
setSeller(await sellerRes.json());
const inventoryRes = await fetch(`/api/inventory/${params.id}`); // 100ms
setInventory(await inventoryRes.json());
setLoading(false);
// Total: 200 + 300 + 400 + 150 + 100 = 1150ms de red + overhead de hidratación
}
load();
}, [params.id]);
if (loading) return <div>Cargando...</div>;
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
<p>Vendido por: {seller.name}</p>
<p>En stock: {inventory.quantity}</p>
<ReviewList reviews={reviews} />
<RelatedProducts products={related} />
</div>
);
}
// ---- DESPUÉS: Obtención paralela desde Server Component — 400ms total (65% más rápido) ----
// app/products/[id]/page.tsx — Server Component
import { Suspense } from "react";
import { db } from "@/lib/db";
import { notFound } from "next/navigation";
import { ReviewList } from "./ReviewList";
import { RelatedProducts } from "./RelatedProducts";
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// Primera captura: datos del producto (necesario para consultas de vendedor y relacionadas)
const product = await db.product.findUnique({
where: { id },
include: { seller: true }, // JOIN en lugar de captura separada — elimina N+1
});
if (!product) notFound();
// Segunda onda: Capturas PARALELAS para datos independientes
const [reviews, related, inventory] = await Promise.all([
db.review.findMany({
where: { productId: id },
orderBy: { createdAt: "desc" },
take: 20,
}),
db.product.findMany({
where: { category: product.category, id: { not: id } },
take: 6,
}),
db.inventory.findUnique({ where: { productId: id } }),
]);
// Total: 200ms (producto+vendedor) + max(300ms, 400ms, 100ms) = 200 + 400 = 600ms
// Pero con caché: producto en caché de visitas anteriores, así ~400ms
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
<p>Vendido por: {product.seller.name}</p>
<p>En stock: {inventory?.quantity ?? 0}</p>
<Suspense fallback={<div className="h-64 animate-pulse" />}>
<ReviewList reviews={reviews} productId={id} />
</Suspense>
<Suspense fallback={<div className="h-48 animate-pulse" />}>
<RelatedProducts products={related} />
</Suspense>
</div>
);
}
// Aún más rápido: dividir en secciones con streaming
// app/products/[id]/page.tsx — Máximo paralelismo con streaming
export default async function ProductPageStreaming({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await db.product.findUnique({
where: { id },
include: { seller: true },
});
if (!product) notFound();
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
<p>Vendido por: {product.seller.name}</p>
{/* Cada sección hace streaming independientemente */}
<Suspense fallback={<InventorySkeleton />}>
<InventoryBadge productId={id} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewSection productId={id} />
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedSection category={product.category} excludeId={id} />
</Suspense>
</div>
);
}
// Cada sección obtiene sus propios datos — todos hacen streaming en paralelo
async function InventoryBadge({ productId }: { productId: string }) {
const inventory = await db.inventory.findUnique({ where: { productId } });
return <p>En stock: {inventory?.quantity ?? 0}</p>;
}
async function ReviewSection({ productId }: { productId: string }) {
const reviews = await db.review.findMany({
where: { productId },
orderBy: { createdAt: "desc" },
take: 20,
});
return <ReviewList reviews={reviews} productId={productId} />;
}
async function RelatedSection({
category,
excludeId,
}: {
category: string;
excludeId: string;
}) {
const related = await db.product.findMany({
where: { category, id: { not: excludeId } },
take: 6,
});
return <RelatedProducts products={related} />;
}Lo que demuestra esto:
- Cascada: 5 capturas secuenciales = 1150ms total
- Paralelo: grupos de
Promise.all= 600ms total (48% más rápido) - Streaming: secciones de Suspense independientes = 200ms primer renderizado, 400ms completo (65% más rápido)
- Prisma
includereemplaza captura de vendedor separada: elimina 1 ida y vuelta (prevención de N+1) - Server Component: sin JavaScript cliente para toda la obtención de datos, sin gestión de estado de carga/error
Análisis Profundo
Cómo Funciona
- Obtención de datos desde Server Component se ejecuta en el servidor con acceso directo a la base de datos, eliminando la ida y vuelta de la ruta API y enviando cero JavaScript al cliente. Los datos se serializan en la carga de React Server Component.
- Paralelismo de
Promise.allinicia todas las promesas simultáneamente y se resuelve cuando la más lenta se completa. El tiempo total es igual al tiempo de captura individual máximo, no la suma. - Detección de cascada — Una cascada ocurre cuando la captura B depende del resultado de la captura A, pero la dependencia es artificial. Si B no necesita realmente los datos de A, pueden ejecutarse en paralelo.
- Consultas N+1 — Obtener una lista de productos y luego obtener el vendedor para cada producto individualmente crea consultas N+1 (1 para la lista + N para cada vendedor). El
includeoselectde Prisma con relaciones lo resuelve con una única consulta JOIN. - Deduplicación de SWR y TanStack Query — Múltiples componentes solicitando el mismo endpoint dentro de una ventana de tiempo reciben la misma respuesta de una única solicitud de red. Esto previene capturas duplicadas en escenarios de renderizado del lado cliente.
Variaciones
Caché con revalidación:
// Captura con caché de Next.js
async function getProducts() {
const res = await fetch("https://api.example.com/products", {
next: {
revalidate: 3600, // Cachea por 1 hora
tags: ["products"], // Etiqueta para revalidación dirigida
},
});
return res.json();
}
// Revalida etiqueta de caché específica después de mutación
import { revalidateTag } from "next/cache";
async function createProduct(data: ProductData) {
await db.product.create({ data });
revalidateTag("products"); // Invalida caché de productos
}SWR para datos en tiempo real del lado cliente:
"use client";
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
function LiveOrderCount() {
const { data, error, isLoading } = useSWR("/api/orders/count", fetcher, {
refreshInterval: 5000, // Sondea cada 5 segundos
dedupingInterval: 2000, // Deduplica solicitudes dentro de 2s
});
if (isLoading) return <span>...</span>;
if (error) return <span>Error</span>;
return <span>{data.count} órdenes</span>;
}Prefetch para navegación anticipada:
// Prefetch al pasar el mouse — datos listos cuando el usuario hace clic
import { useRouter } from "next/navigation";
function ProductCard({ product }: { product: Product }) {
const router = useRouter();
return (
<div
onMouseEnter={() => router.prefetch(`/products/${product.id}`)}
onClick={() => router.push(`/products/${product.id}`)}
>
{product.name}
</div>
);
}Prevención de N+1 con Prisma:
// MALO: N+1 — 1 consulta para órdenes + N consultas para clientes
const orders = await db.order.findMany();
const ordersWithCustomers = await Promise.all(
orders.map(async (order) => ({
...order,
customer: await db.customer.findUnique({ where: { id: order.customerId } }),
}))
);
// BUENO: Consulta única con JOIN
const orders = await db.order.findMany({
include: { customer: true },
});Notas de TypeScript
Promise.allpreserva tipos de tupla:Promise.all([fetchA(), fetchB()])devuelve[TypeA, TypeB].- SWR y TanStack Query aceptan parámetros de tipo genérico para tipado de respuesta.
- Las props de Server Component con
paramsse escriben comoPromise<{ key: string }>en Next.js 15+.
Gotchas
-
Awaits secuenciales que deberían ser paralelos —
const a = await fetchA(); const b = await fetchB();se ejecuta secuencialmente incluso si B no depende de A. Solución: UsaPromise.all([fetchA(), fetchB()])para capturas independientes. -
Obtener datos en componentes cliente cuando los Server Components funcionan —
useEffectdel lado cliente +fetchagrega latencia de hidratación, estados de carga y JS cliente. Solución: Por defecto usa obtención de datos desde Server Component. Solo usa obtención del lado cliente para datos que cambian después de la carga de página inicial. -
Falta de manejo de errores en Promise.all — Si una promesa se rechaza,
Promise.allse rechaza inmediatamente, perdiendo resultados de otras promesas. Solución: UsaPromise.allSettledcuando resultados parciales son aceptables, o envuelve cada promesa en un try-catch. -
Obtención excesiva de datos — Seleccionar todas las columnas cuando solo necesitas
idynamedesperdicia ancho de banda y memoria. Solución: Usa Prismaselectpara obtener solo los campos que tu componente necesita. -
Desajustes de clave de caché — Diferentes parámetros de consulta para los mismos datos lógicos crean entradas de caché separadas. Solución: Normaliza parámetros de consulta y usa patrones de clave de caché consistentes.
-
N+1 en páginas de lista — Renderizar una lista de elementos donde cada uno obtiene sus propios datos crea solicitudes N+1. Solución: Obtén todos los datos en el componente padre con una única consulta que incluya relaciones.
Alternativas
| Enfoque | Compensación |
|---|---|
| Server Component fetch | Sin JavaScript cliente; sin actualizaciones en tiempo real |
| SWR | Revalidación automática, dedup; overhead de JS cliente |
| TanStack Query | Caché poderoso, paginación; más pesado que SWR |
Promise.all | Paralelismo simple; manejo de errores de todo o nada |
Promise.allSettled | Resultados parciales en caso de fallo; manejo de resultado más complejo |
| Streaming con Suspense | Renderizado progresivo; requiere diseño de skeleton |
| GraphQL | Obtención de datos precisa; overhead de esquema |
FAQs
¿Por qué Promise.all es más rápido que awaits secuenciales para capturas independientes?
Promise.allinicia todas las promesas simultáneamente.- El tiempo total es igual a la captura individual más lenta, no la suma de todas las capturas.
- Ejemplo: tres capturas de 50ms, 120ms y 80ms se completan en 120ms en lugar de 250ms.
¿Qué es una consulta N+1 y cómo la arreglas con Prisma?
- Obtener una lista (1 consulta) y luego obtener un registro relacionado para cada elemento (N consultas) = consultas N+1.
- Solución: Usa Prisma
includeoselectcon relaciones para resolverlo en una única consulta JOIN.
// N+1: 1 + N consultas
const orders = await db.order.findMany();
for (const o of orders) {
o.customer = await db.customer.findUnique({ where: { id: o.customerId } });
}
// Arreglado: 1 consulta con JOIN
const orders = await db.order.findMany({ include: { customer: true } });¿Cuándo deberías usar obtención del lado cliente (SWR/TanStack Query) en lugar de Server Components?
- Cuando los datos cambian después de la carga de página inicial (p. ej., conteos de órdenes en vivo, dashboards en tiempo real).
- Cuando datos específicos del usuario deben actualizarse sin una actualización de página completa.
- Los Server Components son el defecto para datos iniciales; la obtención del cliente es para interactividad post-carga.
¿Cómo mejora Suspense streaming el rendimiento percibido para la obtención de datos?
- Cada límite de
<Suspense>hace streaming independientemente a medida que se resuelven sus datos. - El usuario ve el shell de página y secciones rápidas inmediatamente, mientras que secciones más lentas muestran skeletons.
- El tiempo del primer renderizado es igual a la sección más rápida, no la más lenta.
Gotcha: ¿Qué sucede si una promesa en Promise.all se rechaza?
Promise.allse rechaza inmediatamente, descartando resultados de otras promesas.- Solución: Usa
Promise.allSettledcuando resultados parciales son aceptables, o envuelve cada promesa en try-catch.
const [statsResult, ordersResult] = await Promise.allSettled([
fetchStats(),
fetchOrders(),
]);
const stats = statsResult.status === "fulfilled" ? statsResult.value : null;¿Por qué obtener datos en un useEffect cliente es peor que obtención desde Server Component?
- Del lado cliente: el navegador descarga JS, hidrata, luego inicia la captura -- agregando cientos de ms.
- Server Component: los datos se obtienen en el servidor e incluyen en el HTML inicial.
- El enfoque cliente también requiere gestionar estados de carga/error y envía JS extra al cliente.
¿Cómo haces prefetch de datos para navegación anticipada?
import { useRouter } from "next/navigation";
function ProductCard({ product }: { product: Product }) {
const router = useRouter();
return (
<div
onMouseEnter={() => router.prefetch(`/products/${product.id}`)}
onClick={() => router.push(`/products/${product.id}`)}
>
{product.name}
</div>
);
}¿Cómo Promise.all preserva tipos de tupla en TypeScript?
// TypeScript infiere [Stats, Order[], Activity[]]
const [stats, orders, activity] = await Promise.all([
fetchStats(), // Devuelve Promise<Stats>
fetchOrders(), // Devuelve Promise<Order[]>
fetchActivity(), // Devuelve Promise<Activity[]>
]);
// stats: Stats, orders: Order[], activity: Activity[]¿Cómo se tipan params en Server Components de Next.js 15+ con TypeScript?
- En Next.js 15+,
paramses unaPromisey debe ser awaited.
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
}Gotcha: ¿Cuál es el riesgo de obtener datos excesivos de la base de datos?
- Seleccionar todas las columnas cuando solo necesitas
idynamedesperdicia ancho de banda y memoria. - Solución: Usa Prisma
selectpara obtener solo los campos que tu componente renderiza. - Esto es especialmente impactante para páginas de lista que obtienen docenas de registros.
¿Cómo SWR deduplica múltiples solicitudes para el mismo endpoint?
- Múltiples componentes llamando a
useSWRcon la misma clave dentro deldedupingIntervalreciben la misma respuesta de una única solicitud de red. - El
dedupingIntervalpor defecto es 2 segundos. - Esto previene capturas duplicadas cuando varios componentes necesitan los mismos datos.
¿Cuál es la diferencia entre caché a nivel de fetch y a nivel de página en Next.js?
- A nivel de fetch:
next: { revalidate: 3600 }en una llamada específica afetch()cachea esa respuesta. - A nivel de página:
export const revalidate = 3600cachea toda la ruta y todos sus fetches. - A nivel de fetch da control fino; a nivel de página es más simple para caché uniforme.
Relacionado
- Rendimiento de Server Components — Obtención de datos sin JS con RSCs
- Suspense & Streaming — Streaming de secciones de datos independientes
- Caché de Next.js — Estrategias de caché para resultados de fetch
- Checklist de Rendimiento — Elementos de auditoría de obtención de datos