⚡ Skill: optimizacion-rendimiento-react-nextjs

Auditoría de Rendimiento React/Next.js

FlowMetrics SaaS — Next.js 14 App Router · React 18 · TypeScript · TanStack Query · Zustand

5 archivos auditados
9 issues detectados
Estimado: LCP −61% · Bundle −67%
18 Jun 2026
LCP
4.2s
1.6s
Largest Contentful Paint
Crítico
INP
340ms
85ms
Interaction to Next Paint
Crítico
Bundle JS
890KB
290KB
First-load JavaScript
Alto
CLS
0.18
0.02
Cumulative Layout Shift
Medio
P1 — Crítico
Waterfall de 5 awaits secuenciales — DashboardPage
async-waterfall · server-parallel

app/dashboard/page.tsx ejecuta 5 llamadas independientes de forma secuencial. Con p99 de latencia de 200ms por llamada, esto suma ~1000ms de tiempo de servidor innecesario. La regla async-waterfall exige Promise.all para trabajo independiente. Además, la sesión se comprueba antes que un guardado barato (!params.teamId).

✗ Incorrecto — 5 awaits secuenciales
export default async function DashboardPage({
  params
}: { params: { teamId: string } }) {
  // ① Cada await bloquea al siguiente
  const session = await getSession();
  if (!session) redirect("/login");

  const team = await getTeam(params.teamId);
  const user = await getUser(session.userId);
  const events = await getRecentEvents(params.teamId);
  const metrics = await getMetrics(params.teamId);
  const billing = await getBillingStatus(params.teamId);
  // Total: ~5 × RTT de red
}
✓ Correcto — Paralelo + split por componentes
export default async function DashboardPage({
  params
}: { params: { teamId: string } }) {
  // ① Condición barata primero (sin await)
  if (!params.teamId) notFound();

  // ② Auth primero (gating)
  const session = await getSession();
  if (!session) redirect("/login");

  // ③ Paralelo — trabajo independiente
  const [team, user, events, metrics, billing] =
    await Promise.all([
      getTeam(params.teamId),
      getUser(session.userId),
      getRecentEvents(params.teamId),
      getMetrics(params.teamId),
      getBillingStatus(params.teamId),
    ]);
  // Total: max(RTT) ~200ms vs ~1000ms
}
  • LCP −800ms estimado
  • TTFB mejorado
  • Regla: async-parallel
P1 — Crítico
Bundle +600KB por imports masivos — ChartPanel
bundle-direct-imports · bundle-dynamic

components/ChartPanel.tsx importa d3 completo (~500KB), moment (~67KB) y lodash (~70KB). Solo se usan fragmentos mínimos de cada librería. Además importa 4 tipos de chart cuando solo se renderiza uno. Reglas: bundle-direct-imports (evitar barrels), bundle-dynamic (lazy load componentes pesados).

✗ Incorrecto — libs completas
import { LineChart, BarChart,
  PieChart, ScatterChart }
  from "@/components/charts";

import * as d3 from "d3";        // ~500KB
import moment from "moment";     // ~67KB
import _ from "lodash";          // ~70KB

export function ChartPanel({ data, type }) {
  const formatted = moment(data.ts)
    .format("DD/MM/YYYY");
  const sorted = _.sortBy(data.events,
    "timestamp");
}
✓ Correcto — imports directos + lazy
import dynamic from "next/dynamic";
// Charts: lazy por tipo
const charts = {
  line: dynamic(() => import(
    "@/components/charts/LineChart")),
  bar: dynamic(() => import(
    "@/components/charts/BarChart")),
};
// Sin moment: nativo
const fmt = new Intl.DateTimeFormat("es", {
  day:"2-digit",month:"2-digit",year:"numeric"
});
// Sin lodash: nativo
const sorted = [...data.events]
  .sort((a,b) => a.timestamp-b.timestamp);

export function ChartPanel({ data, type }) {
  const Chart = charts[type];
  return <Chart data={data} />;
}
  • Bundle −600KB (67% reducción)
  • TBT mejorado significativamente
  • Regla: bundle-direct-imports, bundle-dynamic
P2 — Alto
Suscripción total al store + componente definido dentro — EventsTable
rerender-store-selector · rerender-no-component-in-component

EventsTable.tsx tiene 3 bugs de re-render: (1) suscripción a todo el store, (2) sort con useEffect + setState en vez de derivar en render, (3) RowActions definido dentro del cuerpo del componente padre — nuevo tipo React en cada render, destruye reconciliación.

✗ Tres anti-patrones juntos
export function EventsTable() {
  // ① store completo = re-render en cualquier cambio
  const store = useStore((s) => s);
  const [sorted, setSorted] = useState([]);

  // ② derivado via useEffect (incorrecto)
  useEffect(() => {
    setSorted(store.events.sort(...));
  }, [store]);

  return sorted.map((evt) => (
    <EventRow key={evt.id} event={evt}
      store={store} />
  ));
}
// ③ componente DENTRO de otro
function EventRow({ event, store }) {
  const RowActions = () => (   // Nuevo tipo cada render
    <button onClick={() =>
      store.deleteEvent(event.id)}>
      Eliminar</button>
  );
  return <div><RowActions /></div>;
}
✓ Selectores granulares + memo + derivado
export function EventsTable() {
  // ① Selector granular
  const events = useStore((s) => s.events);
  const deleteEvent = useStore(
    (s) => s.deleteEvent);

  // ② Derivado en render, no en useEffect
  const sorted = useMemo(
    () => [...events].sort(
      (a,b) => b.timestamp - a.timestamp),
    [events]);

  return sorted.map((evt) => (
    <EventRow key={evt.id} event={evt}
      onDelete={deleteEvent} />
  ));
}
// ③ Componente FUERA, memoizado
const EventRow = memo(function EventRow({
  event, onDelete
}) {
  return (
    <div>
      <button onClick={() =>
        onDelete(event.id)}>
        Eliminar</button>
    </div>
  );
});
  • INP −60% estimado
  • Re-renders eliminados en mutaciones de store no relacionadas
  • Reglas: rerender-selector, rerender-derive-in-render, rerender-no-component-in-component
P2 — Alto
Triple serialización del usuario en RSC layout
server-rsc-serialization · server-cache

app/dashboard/layout.tsx pasa el objeto user a tres Client Components distintos. RSC serializa los datos por cada prop boundary → el payload user viaja 3 veces en el RSC Payload. Además falta React.cache() en getUser() para deduplicar la query de BD cuando se llama desde múltiples RSC del mismo render.

✗ user serializado 3 veces
export default async function DashboardLayout(
  { children }
) {
  const user = await getUser(); // sin cache()

  return (
    <>
      <Sidebar user={user} />  {/* serializa */}
      <Header user={user} />   {/* serializa */}
      <Topbar user={user} />   {/* serializa */}
      {children}
    </>
  );
}
✓ cache() + UserProvider shared
// lib/queries.ts
export const getUser = cache(async () => {
  return db.user.findUnique(...);
});

// layout.tsx
export default async function DashboardLayout(
  { children }
) {
  const user = await getUser(); // deduplicado
  return (
    // UserProvider serializa user una sola vez
    <UserProvider user={user}>
      <Sidebar />
      <Header />
      <Topbar />
      {children}
    </UserProvider>
  );
}
  • RSC Payload −67% para datos de usuario
  • Query de BD deduplicada por request
  • Reglas: server-rsc-cache, server-minimize-props
P3 — Medio
Scroll listener no pasivo — useScrollDepth
client-passive-listener · client-deduplicate-listeners

hooks/useScrollDepth.ts añade el listener sin { passive: true }, lo que bloquea el hilo de composición en cada evento de scroll. El navegador espera saber si el handler llama a preventDefault() antes de pintar el siguiente frame. Además cada componente que use el hook añade su propio listener en lugar de compartir uno.

✗ Listener bloqueante y duplicado
export function useScrollDepth() {
  useEffect(() => {
    window.addEventListener("scroll", handler);
    return () =>
      window.removeEventListener(
        "scroll", handler);
  }, []);
}
// Cada componente = nuevo listener
✓ Pasivo + singleton compartido
// Singleton a nivel módulo
const scrollSubs = new Set<() => void>();
let installed = false;
function ensureListener() {
  if (installed) return;
  window.addEventListener("scroll",
    () => scrollSubs.forEach(fn => fn()),
    { passive: true }); // ← clave
  installed = true;
}
export function useScrollDepth() {
  const [depth, setDepth] = useState(0);
  useEffect(() => {
    ensureListener();
    const handler = () => setDepth(
      Math.round(window.scrollY /
      document.body.scrollHeight * 100));
    scrollSubs.add(handler);
    return () => scrollSubs.delete(handler);
  }, []);
}
  • Scroll FPS mejorado
  • Listener único en vez de N
  • Regla: client-passive-listener, client-deduplicate
# Archivo Regla Severidad Métrica afectada Acción
1 app/dashboard/page.tsx async-parallel Crítico LCP, TTFB Promise.all para 4 fetches independientes
2 app/dashboard/page.tsx async-cheap-first Crítico TTFB Mover !teamId antes del primer await
3 components/ChartPanel.tsx bundle-direct-imports Crítico Bundle, TBT Eliminar d3 completo, moment y lodash
4 components/ChartPanel.tsx bundle-dynamic Crítico Bundle, LCP dynamic() por tipo de chart
5 components/EventsTable.tsx rerender-store-selector Alto INP Selector granular en lugar de (s) => s
6 components/EventsTable.tsx rerender-derive-in-render Alto INP useMemo en render, eliminar useEffect
7 components/EventsTable.tsx rerender-no-inline-component Alto INP, CLS RowActions fuera del componente padre
8 app/dashboard/layout.tsx server-rsc-cache Medio TTFB, Payload RSC React.cache() + UserProvider único
9 hooks/useScrollDepth.ts client-passive-listener Medio FPS scroll { passive: true } + singleton subject
  • 🔴Sprint 1 (esta semana): Promise.all en DashboardPage + eliminar d3/moment/lodash de ChartPanel. Impacto directo en LCP y bundle.
  • 🔴Sprint 1: Migrar imports de charts a dynamic() con Skeleton de carga. Medible con @next/bundle-analyzer.
  • 🟡Sprint 2: Refactorizar EventsTable — selectores granulares Zustand, derivar sorted en useMemo, extraer EventRow con memo().
  • 🟡Sprint 2: Añadir React.cache() a getUser() y UserProvider en layout. Verificar con React DevTools Profiler.
  • 🟢Sprint 3: Passive scroll listener + shared singleton. Activar next.config.js optimizePackageImports para el resto de libs.
  • 🟢Continuo: Añadir @next/bundle-analyzer al pipeline CI para detectar regresiones de bundle en PRs.