Programación Web · Capítulo 18

Optimización y Performance Web: Velocidad que Importa

Aprende a medir y mejorar la velocidad de tu aplicación web, porque cada segundo de carga perdido cuesta usuarios y dinero.


1. Por Qué el Performance Importa

Google encontró que el 53% de los usuarios abandona un sitio móvil si tarda más de 3 segundos en cargar. Amazon calculó que cada 100ms de latencia adicional cuesta 1% de ventas. La velocidad no es solo una preferencia — afecta directamente el posicionamiento en buscadores (SEO) y los ingresos.

2. Core Web Vitals: Las Métricas que Google Mide

Google usa tres métricas principales para evaluar la experiencia del usuario y afectan el ranking en búsquedas:

LCP (Largest Contentful Paint): tiempo hasta que el elemento más grande es visible. Meta: < 2.5s
FID (First Input Delay): tiempo hasta que la página responde al primer clic. Meta: < 100ms
CLS (Cumulative Layout Shift): cuánto se mueven los elementos mientras carga. Meta: < 0.1
MétricaBuenoNecesita mejoraMalo
LCP< 2.5s2.5s – 4s> 4s
FID< 100ms100ms – 300ms> 300ms
CLS< 0.10.1 – 0.25> 0.25

3. Herramientas de Medición

4. Optimización de Imágenes

Las imágenes representan típicamente el 60-70% del peso de una página web. Optimizarlas tiene el mayor impacto.

<!-- Formato moderno WebP (30-50% más pequeño que JPEG) con fallback --> <picture> <source srcset="hero.webp" type="image/webp"> <source srcset="hero.avif" type="image/avif"> <img src="hero.jpg" alt="Imagen principal" width="1200" height="600"> </picture> <!-- Lazy loading: solo cargar cuando está cerca del viewport --> <img src="foto.webp" alt="Foto" loading="lazy" decoding="async"> <!-- Responsive images: diferente tamaño según la pantalla --> <img src="imagen-800.webp" srcset="imagen-400.webp 400w, imagen-800.webp 800w, imagen-1200.webp 1200w" sizes="(max-width: 640px) 400px, (max-width: 1024px) 800px, 1200px" alt="Imagen responsiva" loading="lazy" > <!-- Siempre especificar width y height para evitar CLS --> <img src="logo.png" width="200" height="60" alt="Logo">

5. Optimización de JavaScript

Code Splitting con React

// Sin code splitting: TODO el JS se carga al inicio import PaginaAdmin from "./PaginaAdmin"; // Cargado siempre import PaginaReportes from "./PaginaReportes"; // Cargado siempre // Con code splitting: solo se carga cuando el usuario navega allí import { lazy, Suspense } from "react"; const PaginaAdmin = lazy(() => import("./PaginaAdmin")); const PaginaReportes = lazy(() => import("./PaginaReportes")); function App() { return ( <Suspense fallback={<div>Cargando...</div>}> <Routes> <Route path="/admin" element={<PaginaAdmin />} /> <Route path="/reportes" element={<PaginaReportes />} /> </Routes> </Suspense> ); } // El bundle inicial puede reducirse un 50-80%

defer y async en Scripts

<!-- Sin atributo: bloquea el parser HTML hasta descargar y ejecutar --> <script src="app.js"></script> <!-- async: descarga en paralelo, ejecuta tan pronto como descarga --> <!-- Para scripts independientes (analytics, publicidad) --> <script async src="analytics.js"></script> <!-- defer: descarga en paralelo, ejecuta DESPUÉS del HTML completo --> <!-- Para scripts que necesitan el DOM listo --> <script defer src="app.js"></script>

6. Estrategias de Caché

// Express: configurar headers de caché // Activos estáticos (CSS, JS, imágenes con hash en el nombre) // El hash en el nombre garantiza que si cambia el archivo, cambia la URL app.use("/static", express.static("public", { maxAge: "1y", // Caché por 1 año — el hash asegura invalidación immutable: true })); // API: no cachear respuestas dinámicas app.get("/api/datos", (req, res) => { res.set("Cache-Control", "no-store"); res.json(datos); }); // HTML: revalidar en cada visita app.get("/", (req, res) => { res.set("Cache-Control", "no-cache"); // Revalida antes de usar caché res.sendFile("index.html"); });
// Vite genera nombres con hash automáticamente: // app.js → app.a1b2c3d4.js // styles.css → styles.e5f6g7h8.css // Esto permite cache-busting automático: // - Si el archivo NO cambió → misma URL → se sirve desde caché // - Si el archivo SÍ cambió → URL diferente → se descarga nuevo

7. Optimización de CSS

/* Critical CSS: estilos para lo visible "above the fold" Inlinar en el HTML para evitar bloqueo de renderizado */ /* En el <head> del HTML: */ <style> /* Solo estilos del header, hero y lo visible sin scroll */ body { font-family: sans-serif; margin: 0; } .hero { background: #0f172a; color: white; padding: 60px; } </style> /* El resto del CSS carga de forma no bloqueante: */ <link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'"> <noscript><link rel="stylesheet" href="styles.css"></noscript>
// Tailwind CSS elimina el CSS no usado automáticamente (PurgeCSS): // tailwind.config.js module.exports = { content: ["./src/**/*.{js,jsx,ts,tsx}"], // Tailwind escanea estos archivos y elimina clases no usadas // Un proyecto típico pasa de 3MB a menos de 20KB de CSS

8. Prefetch y Preload

<head> <!-- Preload: recurso crítico que necesitarás PRONTO en esta página --> <link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="/hero.webp" as="image"> <!-- Prefetch: recurso que probablemente necesitarás en la PRÓXIMA página --> <link rel="prefetch" href="/dashboard.js"> <!-- Preconnect: establecer conexión TCP/TLS temprana con dominio externo --> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://api.ejemplo.com"> </head>

9. Optimización de Consultas a Base de Datos

// ❌ Problema N+1: una consulta por cada usuario const usuarios = await db.query("SELECT * FROM usuarios"); for (const usuario of usuarios) { // Esto hace 1 + N consultas (N = número de usuarios) usuario.pedidos = await db.query( "SELECT * FROM pedidos WHERE usuario_id = ?", [usuario.id] ); } // ✅ Solución: JOIN en una sola consulta const usuariosConPedidos = await db.query(` SELECT u.*, p.id as pedido_id, p.total, p.fecha FROM usuarios u LEFT JOIN pedidos p ON u.id = p.usuario_id `); // O usando carga eager con un ORM como Prisma: const usuarios = await prisma.usuario.findMany({ include: { pedidos: true } // Un solo query optimizado });

Resumen del Capítulo