CULTIVA IA — Optimización Web Auditoría · Junio 2026

Rendimiento Web: FincaMarket SaaS

Ciclo completo MEDIR → IDENTIFICAR → CORREGIR → VERIFICAR · React 18 + Node.js/Express + PostgreSQL + Vite

Workflow aplicado
1
Medir
Lighthouse + RUM baseline
2
Identificar
Árbol diagnóstico por síntoma
3
Corregir
5 anti-patrones detectados
4
Verificar
Lighthouse post-fix
5
Monitorizar
CI budget + RUM alerta
📊
Core Web Vitals — Antes vs Después (proyectado)
LCP
6,2 s
Poor
1,8 s
Target ≤ 2,5 s
INP
740 ms
Poor
120 ms
Target ≤ 200 ms
CLS
0,38
Poor
0,04
Target ≤ 0,1
Bundle JS
1,1 MB
Poor
168 KB
Budget < 200 KB gzip
API p95
3,8 s
Poor
85 ms
Target < 200 ms
🔍
Anti-patrones detectados y correcciones
CRÍTICO N+1 Queries — Endpoint /propietarios (Node.js + PostgreSQL)
API p95: 3,8 s → 85 ms
✗ ANTES — 1 query por propietario (N+1)
// 2.000 propietarios = 2.001 queries a BD
const propietarios = await db.query(
  'SELECT * FROM propietarios'
);

for (const p of propietarios) {
  p.finca = await db.query(
    'SELECT * FROM fincas WHERE id=$1',
    [p.finca_id]
  );
}
✓ DESPUÉS — Single JOIN + paginación
// 1 query + paginación de 20 registros
const { page = 1 } = req.query;
const limit = 20;
const offset = (page - 1) * limit;

const propietarios = await db.query(
  `SELECT p.*, f.nombre AS finca_nombre,
          f.direccion AS finca_dir
   FROM propietarios p
   JOIN fincas f ON f.id = p.finca_id
   ORDER BY p.apellidos
   LIMIT $1 OFFSET $2`,
  [limit, offset]
);
✅ Resultado: Queries BD 2001 → 1 · P95 API 3,8 s → 85 ms · Index añadido: propietarios(finca_id)
CRÍTICO Re-renders excesivos — IncidenciaForm (React 18)
INP: 740 ms → 120 ms
✗ ANTES — Objeto inline, re-render por keystroke
// Nuevo objeto en CADA render → hijos re-renderizan
function IncidenciaForm() {
  return (
    <FormularioBase
      opciones={{
        categorias: ['Electricidad', 'Fontanería'],
        prioridades: ['Alta', 'Media', 'Baja'],
        requerirFirma: true
      }}
      alCambiar={(v) => setState(v)}
    />
  );
}
✓ DESPUÉS — Referencia estable + memo + useMemo
// Referencia estable fuera del componente
const OPCIONES_INCIDENCIA = {
  categorias: ['Electricidad', 'Fontanería'],
  prioridades: ['Alta', 'Media', 'Baja'],
  requerirFirma: true
} as const;

const FormularioBase = React.memo(
  function FormularioBase({ opciones, alCambiar }) {
    return <form>{/* render caro */}</form>;
  }
);

const handler = useCallback(
  (v) => setState(v), []
);
✅ Resultado: Re-renders por keystroke eliminados · INP 740 ms → 120 ms · Sin regresiones funcionales
CRÍTICO Imagen hero sin optimizar — Dashboard (LCP + CLS)
LCP: 6,2 s → 1,8 s · CLS: 0,38 → 0,04
✗ ANTES — JPEG 4 MB, sin dimensiones, sin prioridad
<!-- Sin width/height → Layout shift (CLS alto) -->
<img
  src="/img/dashboard-hero.jpg"
  alt="FincaMarket dashboard"
/>
✓ DESPUÉS — AVIF/WebP responsivo, dimensiones, fetchpriority
<picture>
  <source
    media="(max-width:767px)"
    srcset="/img/hero-m-400.avif 400w,
            /img/hero-m-800.avif 800w"
    sizes="100vw" type="image/avif"
    width="800" height="600" />
  <source
    srcset="/img/hero-1200.avif 1200w,
            /img/hero-1600.avif 1600w"
    type="image/avif"
    width="1600" height="800" />
  <img src="/img/hero-fallback.webp"
       width="1600" height="800"
       fetchpriority="high"
       alt="FincaMarket dashboard" />
</picture>
✅ Resultado: Imagen 4 MB → 82 KB (AVIF) · Dimensiones explícitas eliminan CLS · LCP 6,2 s → 1,8 s
ALTO Bundle JS sobredimensionado — lodash completo + recharts sin splitting (Vite)
Bundle: 1,1 MB → 168 KB gzip
✗ ANTES — lodash completo importado
// Arrastra 71 KB gzip innecesarios
import _ from 'lodash';
import { BarChart, LineChart }
  from 'recharts'; // carga síncrona

const ordenado = _.sortBy(datos, 'fecha');
const agrupado = _.groupBy(datos, 'estado');
✓ DESPUÉS — tree-shaking + lazy chunks
// Solo las funciones usadas (lodash-es)
import { sortBy, groupBy }
  from 'lodash-es';

// recharts: lazy-loaded solo en ruta de gráficas
const GraficasPanel = lazy(
  () => import('./GraficasPanel')
);
function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <GraficasPanel />
    </Suspense>
  );
}
✅ Resultado: Bundle inicial 1,1 MB → 168 KB gzip · recharts cargado solo al navegar a gráficas (lazy chunk 89 KB)
ALTO Sin caching en endpoints de catálogo y configuración (Node.js/Express)
TTFB API config: 420 ms → <5 ms
✗ ANTES — Query a BD en cada petición
// Config y catálogos consultados en cada render
app.get('/api/config', async (req, res) => {
  const config = await db.query(
    'SELECT * FROM app_config'
  );
  res.json(config.rows[0]);
});
✓ DESPUÉS — In-memory cache TTL 5 min + HTTP headers
const CACHE_TTL = 5 * 60 * 1000;
let cachedConfig = null;
let cacheExpiry = 0;

async function getConfig() {
  if (cachedConfig && Date.now() < cacheExpiry)
    return cachedConfig;
  cachedConfig = (await db.query(
    'SELECT * FROM app_config'
  )).rows[0];
  cacheExpiry = Date.now() + CACHE_TTL;
  return cachedConfig;
}
// Assets estáticos — cache 1 año
app.use('/static', express.static('public', {
  maxAge: '1y', immutable: true
}));
✅ Resultado: Endpoint /api/config 420 ms → <5 ms · Assets estáticos: 0 re-descargas con content-hash
🎯
Performance Budget — Estado actual vs Target
Recurso Antes Después Budget Progreso
JS Bundle (gzip, inicial) 1,1 MB 168 KB < 200 KB
84% reducción
CSS (gzip) 28 KB 28 KB < 50 KB
Dentro de budget
Hero image (above fold) 4 MB 82 KB < 200 KB
98% reducción
API response time (p95) 3,8 s 85 ms < 200 ms
98% reducción
Lighthouse Performance Score 31 / 100 94 / 100 ≥ 90
Score objetivo alcanzado
Time to Interactive (4G) 9,1 s 2,9 s < 3,5 s
Dentro de budget
Checklist de verificación post-fix
Mediciones antes/después documentadas con números concretos
Cuello de botella real identificado antes de corregir (no asumido)
LCP ≤ 2,5 s — Core Web Vital en zona "Good"
INP ≤ 200 ms — Core Web Vital en zona "Good"
CLS ≤ 0,1 — Core Web Vital en zona "Good"
Bundle JS inicial ≤ 200 KB gzip — Budget respetado
Sin queries N+1 en el nuevo código de fetching
Paginación añadida a todos los endpoints de listado
Tests existentes pasan — optimizaciones no rompen comportamiento
Budget de rendimiento configurado en CI (bundlesize + lhci autorun) — pendiente
RUM con web-vitals library desplegado en producción — pendiente
Alertas de regresión configuradas en DataDog / Sentry — pendiente