Performance Audit

NutriTrack Pro

Informe de auditoría de rendimiento — React 18 + Node.js + PostgreSQL
Cliente NutriTrack Pro SaaS
Fecha 18 jun 2026
Stack React 18 / Webpack 5 / Express / PostgreSQL
Entorno Vercel + Railway (Producción)
34/100
Lighthouse Score Actual
Core Web Vitals — Estado Actual
CRÍTICO
LCP
5.8s
Objetivo: < 2.5s
CRÍTICO
INP
420ms
Objetivo: < 200ms
CRÍTICO
CLS
0.31
Objetivo: < 0.1
CRÍTICO
TTI
7.2s
Objetivo: < 3.8s
FALLO
TBT
680ms
Objetivo: < 200ms
CRÍTICO
Bundle (gzip)
847KB
Objetivo: < 200 KB
📦
Análisis del Bundle
Módulo Tamaño (gzip) % del Total Visualización Estado Acción
moment.js 67 KB 7.9%
Reemplazar Usar date-fns (~13 KB)
lodash (todo) 71 KB 8.4%
Reemplazar Importar sólo lo necesario / lodash-es
recharts 145 KB 17.1%
Lazy load React.lazy + code splitting por ruta
@mui/material 198 KB 23.4%
Tree-shake Imports específicos, bundle-split por tema
@react-pdf/renderer 112 KB 13.2%
Lazy load Cargar sólo al exportar PDF
Código propio 142 KB 16.8%
Revisar Dead code elimination (knip)
Otros vendors 112 KB 13.2%
Monitorizar Auditar duplicados con duplicate-checker
🔴
Problemas Críticos Detectados
Crítico Lista de pacientes: renderizado O(n²) sin virtualización +420ms INP · +3s TTI
src/pages/Patients/PatientList.tsx : 47–89
ANTES — O(n²), sin memoización, sin virtualización
const PatientList = () => { const patients = useSelector(state => state.patients); // 500+ items // ❌ Ordenar en cada render const sorted = patients.sort((a, b) => a.name.localeCompare(b.name)); // ❌ filter() en cada render const filtered = sorted.filter(p => allDietPlans.find(d => d.patientId === p.id) // O(n) por paciente! ); return filtered.map((p, i) => ( <PatientRow key={i} patient={p} // ❌ index como key onClick={() => handleSelect(p.id)} // ❌ nueva fn por render /> )); };
DESPUÉS — useMemo + useCallback + virtualización
import { FixedSizeList } from 'react-window'; const PatientList = () => { const patients = useSelector(state => state.patients); const dietPlans = useSelector(state => state.dietPlans); // ✅ Map para O(1) lookup const planByPatient = useMemo(() => new Map(dietPlans.map(d => [d.patientId, d])), [dietPlans] ); // ✅ Ordenar una sola vez const sorted = useMemo(() => [...patients].sort((a, b) => a.name.localeCompare(b.name)), [patients] ); const handleSelect = useCallback( (id: string) => dispatch(selectPatient(id)), [dispatch] ); // ✅ Virtualización: sólo renderiza los visibles return ( <FixedSizeList height={600} itemCount={sorted.length} itemSize={72} > {({ index, style }) => ( <PatientRow key={sorted[index].id} // ✅ ID estable patient={sorted[index]} style={style} onClick={handleSelect} // ✅ callback estable /> )} </FixedSizeList> ); };
Crítico N+1 queries: dashboard carga 1+N peticiones al servidor +2.1s LCP · 287 queries por sesión media
src/hooks/useDashboard.ts : 23–45
ANTES — N+1 queries secuenciales
// ❌ 1 query para todos los pacientes const patients = await fetchPatients(); // ❌ luego N queries, una por paciente for (const patient of patients) { patient.stats = await fetchNutritionStats(patient.id); // await en loop! patient.plan = await fetchDietPlan(patient.id); patient.alerts = await fetchAlerts(patient.id); } // Total: 1 + 3*N queries → 286 queries para 95 pacientes
DESPUÉS — batch endpoint + Promise.all
// ✅ Un único endpoint con JOIN en PostgreSQL const [patients, statsMap, plansMap, alertsMap] = await Promise.all([ fetchPatients(), fetchNutritionStatsBatch(), // devuelve Map<id, stats> fetchDietPlansBatch(), // devuelve Map<id, plan> fetchAlertsBatch(), // devuelve Map<id, alerts[]> ]); // Total: 4 queries paralelas (en lugar de 286) // Backend — SQL con JOIN eficiente: // SELECT p.*, ns.*, dp.* FROM patients p // LEFT JOIN nutrition_stats ns ON ns.patient_id = p.id // LEFT JOIN diet_plans dp ON dp.patient_id = p.id // WHERE p.clinic_id = $1;
Alto Fuga de memoria: event listeners sin cleanup en NutritionChart +180 MB tras 20 min · crash en móviles
src/components/charts/NutritionChart.tsx : 78–112
ANTES — sin cleanup
useEffect(() => { window.addEventListener('resize', handleChartResize); websocket.on('nutrition-update', refreshChart); const interval = setInterval(() => pollLiveData(), 5000); // ❌ No hay return de cleanup — todo queda en memoria }, []);
DESPUÉS — cleanup correcto
useEffect(() => { window.addEventListener('resize', handleChartResize); websocket.on('nutrition-update', refreshChart); const interval = setInterval(() => pollLiveData(), 5000); // ✅ Cleanup: eliminar listeners al desmontar return () => { window.removeEventListener('resize', handleChartResize); websocket.off('nutrition-update', refreshChart); clearInterval(interval); }; }, []);
Alto Bundle enorme: recharts + MUI cargados en ruta raíz -343 KB tras code-splitting · -2.1s TTI
src/App.tsx : 1–18 · src/pages/Dashboard.tsx : 1–12
ANTES — todo importado en el bundle principal
import { LineChart, BarChart, PieChart } from 'recharts'; // 145 KB import PDFExport from '@react-pdf/renderer'; // 112 KB import moment from 'moment'; // 67 KB import _ from 'lodash'; // 71 KB // Total en bundle principal: +395 KB innecesarios en ruta /login
DESPUÉS — lazy loading + alternativas ligeras
// ✅ Lazy load de secciones pesadas const ChartsModule = lazy(() => import('./pages/Analytics')); const PDFExport = lazy(() => import('./components/PDFExport')); // ✅ Alternativas más ligeras import { format, addDays } from 'date-fns'; // 13 KB vs 67 KB import { debounce } from 'lodash-es/debounce'; // 3 KB vs 71 KB // ✅ Rutas con Suspense return ( <Suspense fallback=<LoadingSpinner />> <Routes> <Route path="/analytics" element=<ChartsModule /> /> </Routes> </Suspense> );
📈
Mejora Proyectada tras Aplicar las Optimizaciones
Lighthouse Score
34 91
+57 puntos (+168%)
LCP
5.8s 1.9s
-3.9s (-67%)
INP
420ms 85ms
-335ms (-80%)
CLS
0.31 0.04
-0.27 (-87%)
Bundle (gzip)
847 KB 168 KB
-679 KB (-80%)
Queries/sesión
287 12
-275 queries (-96%)
🗺️
Plan de Optimización por Fases
Fase 1 — Sprint 1 (semana 1)
Wins rápidos: memoria y queries
  • Añadir cleanup en todos los useEffect con listeners/timers
  • Crear endpoint batch para dashboard (1 query vs 287)
  • Reemplazar moment.js por date-fns
  • Cambiar import lodash por lodash-es (tree-shakeable)
  • Auditar duplicados con duplicate-package-checker
Impacto estimado: -179 KB bundle · -80% queries
Fase 2 — Sprint 2 (semana 2)
React: memoización y virtualización
  • PatientList: react-window FixedSizeList
  • useMemo para ordenación y filtrado de listas
  • useCallback para handlers pasados a hijos
  • React.memo en PatientRow, NutritionCard, AlertBadge
  • Reemplazar index keys por IDs estables
Impacto estimado: INP 420ms → 85ms · -330ms TTI
Fase 3 — Sprint 3 (semana 3)
Code splitting y bundle
  • React.lazy en ChartsModule y PDFExport
  • Code splitting a nivel de ruta con react-router
  • Suspense boundaries con skeleton loaders
  • Preload hints para chunks críticos
  • Añadir performance budget en CI (bundlesize)
Impacto estimado: bundle 847 KB → 168 KB (-80%)
Fase 4 — Sprint 4 (semana 4)
PostgreSQL + monitorización
  • Añadir índices: patients(clinic_id, active), plans(patient_id)
  • Eliminar SELECT * en todas las queries de producción
  • Implementar caching de sesión con Redis (TTL 5 min)
  • Integrar web-vitals SDK para tracking continuo
  • Alerta en CI si Lighthouse cae de 85
Impacto estimado: p95 query time 1.2s → 120ms
Checklist de Rendimiento Completo
React Performance
useMemo para computaciones costosas
useCallback para funciones pasadas a hijos
React.memo en componentes frecuentes
Dependency arrays correctas en hooks
Virtualización de listas largas
React 18 Concurrent Mode habilitado
Bundle & Red
Lazy loading de componentes pesados
Code splitting por ruta
Tree shaking activado correctamente
Alternativas ligeras (date-fns, lodash-es)
Gzip activado en Vercel
CDN para assets estáticos
Base de Datos
Índices en columnas frecuentes
Eliminar SELECT * en producción
Caching de resultados de query (Redis)
Paginación en listas grandes
Connection pooling (pg-pool)
Slow query log habilitado
Memoria & Monitorización
Cleanup en event listeners (useEffect)
Cleanup en timers e intervalos
Web Vitals SDK integrado
Performance budget en CI
Sentry de errores activo
Logs de Railway monitorizados