Un restaurante lento pierde clientes
Imagina un restaurante con la mejor comida del mundo, pero donde cada platillo tarda 45 minutos. No importa que tan bueno sea, los clientes se van.
Las aplicaciones web son igual. Cada segundo de carga puede costar hasta 7% de conversiones.
Performance no es optimizacion prematura. Es respeto por el tiempo del usuario.
Core Web Vitals: Las metricas que importan
Google mide 3 metricas principales que afectan SEO y experiencia:
┌─────────────────────────────────────────────────────────┐
│ CORE WEB VITALS │
├─────────────────┬─────────────────┬─────────────────────┤
│ LCP │ INP │ CLS │
│ < 2.5s │ < 200ms │ < 0.1 │
│ │ │ │
│ Largest │ Interaction │ Cumulative │
│ Contentful │ to Next │ Layout │
│ Paint │ Paint │ Shift │
│ │ │ │
│ "Carga rapida" │ "Responde bien" │ "Estable visualmente│
└─────────────────┴─────────────────┴─────────────────────┘
| Metrica | Que mide | Bueno | Malo |
|---|---|---|---|
| LCP | Cuanto tarda el contenido principal | < 2.5s | > 4s |
| INP | Respuesta a interacciones | < 200ms | > 500ms |
| CLS | Cuanto salta el layout | < 0.1 | > 0.25 |
El pipeline del navegador
Entender como renderiza el navegador te ayuda a optimizar.
HTML ──→ DOM
│
CSS ──→ CSSOM ──→ Render Tree ──→ Layout ──→ Paint ──→ Composite
Que bloquea el renderizado
- JavaScript sincrono: Bloquea parsing del HTML
- CSS en el <head>: Bloquea render (pero necesario)
- Fonts externas: Pueden causar FOIT/FOUT
JavaScript: El cuello de botella
El Event Loop
┌─────────────────────────────────────────────────┐
│ CALL STACK │
│ (Ejecuta codigo sincrono, uno a la vez) │
└─────────────────────────────────────────────────┘
▲ │
│ ▼
┌────────┴────────┐ ┌─────────────────────────┐
│ TASK QUEUE │ │ MICROTASK QUEUE │
│ (setTimeout, │ │ (Promises, async/await)│
│ eventos) │ │ (Mayor prioridad) │
└─────────────────┘ └─────────────────────────┘
Problema: Bloqueo del main thread
// MAL: Bloquea el UI por 500ms
function processData(items) {
items.forEach(item => heavyCalculation(item));
}
// BIEN: Divide en chunks
async function processDataAsync(items) {
const chunkSize = 100;
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
chunk.forEach(item => heavyCalculation(item));
await new Promise(r => setTimeout(r, 0)); // Yield al browser
}
}
Optimizacion de bundles
Code Splitting
// Sin splitting: todo carga al inicio
import { Dashboard } from './Dashboard';
import { Admin } from './Admin';
import { Reports } from './Reports';
// Con splitting: carga bajo demanda
const Dashboard = lazy(() => import('./Dashboard'));
const Admin = lazy(() => import('./Admin'));
const Reports = lazy(() => import('./Reports'));
Tree Shaking
// MAL: Importa toda la libreria (100KB)
import _ from 'lodash';
_.debounce(fn, 300);
// BIEN: Solo lo que necesitas (2KB)
import debounce from 'lodash/debounce';
debounce(fn, 300);
Optimizacion de imagenes
Formatos modernos
| Formato | Uso | Ahorro vs JPEG |
|---|---|---|
| WebP | General, soporte amplio | 25-35% |
| AVIF | Mejor compresion, menos soporte | 50%+ |
Responsive images
<picture>
<source srcset="hero.avif" type="image/avif">
<source srcset="hero.webp" type="image/webp">
<img
src="hero.jpg"
alt="Hero"
loading="lazy"
width="1200"
height="600"
>
</picture>
Next.js Image
import Image from 'next/image';
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // Para imagenes above the fold
placeholder="blur" // Efecto de carga
/>
Caching estrategico
Niveles de cache
Usuario ──→ Browser Cache ──→ CDN Cache ──→ Server ──→ DB
(localStorage, (Edge) (Redis)
sessionStorage)
HTTP Cache Headers
Cache-Control: public, max-age=31536000, immutable
│ │ │
│ │ └─ No revalidar
│ └─ 1 año en segundos
└─ CDN puede cachear
Estrategia por tipo de recurso
| Recurso | Cache-Control | Porque |
|---|---|---|
| JS/CSS con hash | max-age=31536000, immutable | Hash cambia si archivo cambia |
| HTML | no-cache | Siempre validar con server |
| API dinamica | private, max-age=0 | Datos frescos |
| Imagenes estaticas | max-age=86400 | 1 dia, CDN |
Database performance
Indices: La clave
-- Sin indice: Full table scan (lento)
SELECT * FROM users WHERE email = 'test@example.com';
-- Tiempo: 500ms en 1M rows
-- Con indice: Index lookup (rapido)
CREATE INDEX idx_users_email ON users(email);
-- Tiempo: 2ms
EXPLAIN: Entiende tus queries
EXPLAIN ANALYZE SELECT * FROM orders
WHERE user_id = 123 AND created_at > '2024-01-01';
-- Busca:
-- - Seq Scan (malo en tablas grandes)
-- - Index Scan (bueno)
-- - Rows estimados vs reales
El problema N+1
// MAL: N+1 queries
const users = await User.findAll();
for (const user of users) {
user.orders = await Order.findByUser(user.id); // 1 query por user
}
// 1 + N queries
// BIEN: Eager loading
const users = await User.findAll({
include: [{ model: Order }]
});
// 1 query con JOIN
Herramientas de profiling
Chrome DevTools
- Performance tab: Graba timeline de carga
- Network tab: Waterfall de requests
- Lighthouse: Auditoria completa
Que buscar en Performance tab
Timeline:
├── Loading (azul): Parsing HTML
├── Scripting (amarillo): JavaScript
├── Rendering (morado): Layout, style
└── Painting (verde): Dibujando pixeles
Si amarillo domina → Optimiza JS
Si morado domina → Reduce reflows
Checklist de performance
Antes del deploy
- Lighthouse score > 90
- LCP < 2.5s
- Bundle size < 200KB (inicial)
- Imagenes en WebP/AVIF
- Lazy loading en imagenes below fold
- Code splitting activo
- Cache headers configurados
En produccion
- CDN configurado
- Gzip/Brotli activo
- HTTP/2 habilitado
- Indices en queries lentas
- Monitoring de Core Web Vitals
Recursos
Practica
-> Auditoria de Performance - Optimiza una app lenta paso a paso