Ciclo completo MEDIR → IDENTIFICAR → CORREGIR → VERIFICAR · React 18 + Node.js/Express + PostgreSQL + Vite
// 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] ); }
// 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] );
// 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)} /> ); }
// 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), [] );
<!-- Sin width/height → Layout shift (CLS alto) --> <img src="/img/dashboard-hero.jpg" alt="FincaMarket dashboard" />
<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>
// 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');
// 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> ); }
// 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]); });
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 }));
| 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
|