Por qué la mayoría de sitios acaban siendo lentos
Los sitios se vuelven lentos de la misma forma que la mayoría de proyectos se descontrolan: nadie vigila el número, así que el número se desvía.
Un equipo lanza una feature. El bundle pasa de 180KB a 220KB. Nadie se da cuenta. El siguiente sprint alguien añade una librería de date picker y el bundle pasa de 220KB a 310KB. Nadie se da cuenta. Seis meses después la home tarda cuatro segundos en volverse interactiva en un Android de gama media, y el equipo está en una reunión intentando entender qué pasó.
Lo que pasó es que nunca pusieron un presupuesto. Así que este post va sobre presupuestos, y luego sobre las cuatro o cinco técnicas que de verdad mueven la aguja.
Qué es un presupuesto de rendimiento
Un presupuesto de rendimiento es un número con el que te comprometes. Ese es el concepto entero. Ejemplos:
- El bundle de JavaScript de la ruta principal se mantiene por debajo de 150KB comprimido.
- Largest Contentful Paint por debajo de 2,5 segundos en una conexión 4G.
- Time to Interactive por debajo de 3,5 segundos.
- Ninguna tarea larga superior a 200ms durante la carga de la página.
El número vive en algún sitio donde un job de CI pueda leerlo. Cuando un pull request empuja el número por encima del límite, el build falla. La persona que lo escribió o reduce la feature, o quita una dependencia, o argumenta para cambiar el presupuesto. Las tres son conversaciones útiles. Lo malo es la deriva silenciosa.
Una versión simple con size-limit:
{
"size-limit": [
{
"path": "dist/assets/index-*.js",
"limit": "150 KB"
}
]
}
Luego npm run size en CI. Si el bundle crece por encima de 150KB, el job falla. Eso es todo el presupuesto. No hace falta un dashboard sofisticado para empezar.
Para métricas en tiempo de ejecución como LCP e INP, Lighthouse CI hace lo mismo dentro del navegador.
Tareas largas: lo que hace que las páginas se sientan rotas
Cuando el usuario hace clic en un botón y no pasa nada durante medio segundo, normalmente es una tarea larga en el hilo principal. El navegador tiene un solo hilo principal que maneja JavaScript, layout, pintado e input. Mientras se ejecuta JavaScript, el navegador no puede responder al clic. Cualquier cosa por encima de 50ms se considera oficialmente una “tarea larga” y empieza a sentirse a tirones. Cualquier cosa por encima de 200ms se siente rota.
Causas comunes:
- Parsear un JSON grande de forma síncrona.
- Iterar miles de elementos para construir una lista derivada.
- Trabajo pesado dentro de un render de React o un
useEffectque se ejecuta con cada cambio de estado. - Una librería que hace trabajo de inicialización en el momento en que la importas.
La solución no siempre es hacer el trabajo más rápido. A menudo es romper el trabajo en trozos para que el navegador pueda respirar entre ellos.
// Mal: bloquea el hilo principal toda la duración
function processItems(items) {
return items.map(heavyTransform);
}
// Mejor: cede control al navegador entre bloques
async function processItems(items) {
const result = [];
for (let i = 0; i < items.length; i++) {
result.push(heavyTransform(items[i]));
if (i % 50 === 0) {
// Deja que el navegador atienda clicks, pintado, animaciones
await new Promise(resolve => setTimeout(resolve, 0));
}
}
return result;
}
En navegadores modernos también existe scheduler.yield(), que es más limpio que setTimeout(0) y le dice al navegador que atienda input pendiente primero.
Web workers: cuando el trabajo es pesado de verdad
Si estás procesando un CSV de 5MB, decodificando un frame de video, o ejecutando un índice de búsqueda sobre 50.000 documentos, ninguna cantidad de cesión va a salvarte. Ese trabajo va en un hilo worker.
Un worker es un contexto de JavaScript separado que corre en paralelo y no puede tocar el DOM. Le envías datos, te devuelve un resultado. El hilo principal queda libre para clics y animaciones.
// main.js
const worker = new Worker(new URL('./search-worker.js', import.meta.url), {
type: 'module'
});
worker.postMessage({ query: 'astro performance' });
worker.onmessage = (e) => {
renderResults(e.data.results);
};
// search-worker.js
import { buildIndex, search } from './lunr-helpers.js';
const index = buildIndex(); // costoso, pero no estamos bloqueando la UI
self.onmessage = (e) => {
const results = search(index, e.data.query);
self.postMessage({ results });
};
El usuario escribe, el hilo principal sigue fluido, el worker hace el trabajo. Para cosas como búsqueda, filtros de imagen, parsers y criptografía, esta es la respuesta correcta. Para “tengo una lista de 50 elementos que ordenar”, es exagerado.
Librerías como Comlink hacen que la mensajería parezca llamadas a funciones normales si el postMessage crudo te resulta incómodo.
Lazy loading y code splitting en palabras llanas
Lazy loading significa: no lo descargues hasta que lo necesites. Code splitting significa: envía la página en piezas en lugar de un archivo enorme.
La mayoría de routers en frameworks modernos hacen el split por ruta automáticamente. Navegas a /dashboard, el bundle del dashboard se descarga. Nunca visitas /admin, el bundle de admin nunca se carga. Ganancia gratuita.
Las ganancias que tienes que pedir son a nivel de componente. El ejemplo clásico es un editor pesado o una librería de gráficos que solo aparece después de que el usuario hace clic en algo.
import { lazy, Suspense } from 'react';
const RichEditor = lazy(() => import('./RichEditor'));
export function CommentForm() {
const [editing, setEditing] = useState(false);
if (!editing) {
return <button onClick={() => setEditing(true)}>Escribir comentario</button>;
}
return (
<Suspense fallback={<p>Cargando editor...</p>}>
<RichEditor />
</Suspense>
);
}
Ahora el editor solo se envía a usuarios que hacen clic en el botón. En la mayoría de páginas eso es una fracción pequeña de usuarios, y el editor suele pesar cien KB o más. Esta es la forma más barata de recortar tamaño de bundle en una base de código madura: encuentra los componentes más grandes que cargan por defecto y comprueba si hace falta que lo hagan.
En Astro, el equivalente son las directivas client:. client:visible espera a que el componente entre en el viewport. client:idle espera a que el navegador esté libre. client:load lo envía de inmediato. Elegir la correcta para cada isla es toda la historia de rendimiento de un sitio de contenido.
Medir con usuarios reales, no solo con herramientas de laboratorio
Lighthouse y WebPageTest son útiles, pero corren en una máquina limpia con red rápida. Tus usuarios están en un teléfono de hace cinco años, en un café con tres barras de LTE. Las herramientas de laboratorio te dicen lo que es posible. La monitorización de usuarios reales te dice lo que está pasando.
La forma más simple de recoger números reales es la librería web-vitals. Engancha con las APIs del navegador que reportan LCP, INP y CLS, y te da un callback cuando los valores son finales.
import { onLCP, onINP, onCLS } from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
page: location.pathname
});
// sendBeacon sobrevive a la descarga de la página, fetch no siempre
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/vitals', body);
} else {
fetch('/api/vitals', { body, method: 'POST', keepalive: true });
}
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
Esa es toda la instrumentación. El endpoint guarda los números y construyes un dashboard que muestre el percentil 75 por página durante la última semana. Ahora sabes qué rutas son lentas para tus usuarios reales, en sus dispositivos reales, en sus redes reales.
El percentil 75 importa porque el usuario mediano a menudo tiene una experiencia decente. Es el cuarto más lento el que se va. Si tu p75 de LCP es de 4 segundos, una cuarta parte de tus usuarios espera 4 segundos o más.
Un flujo de trabajo razonable
Si partes de un sitio que nunca ha recibido atención de rendimiento, este es más o menos el orden para hacer las cosas:
- Pon un presupuesto de tamaño de bundle. Conéctalo a CI. Deja que el siguiente pull request sienta la fricción.
- Ejecuta web-vitals en producción durante dos semanas. Mira el p75 de LCP e INP por ruta.
- Para la peor ruta, abre la pestaña Performance de Chrome DevTools y graba una carga. Busca las tareas largas. La mayoría de páginas lentas tienen dos o tres culpables obvios.
- Elige una corrección por semana. Carga en lazy un componente pesado. Mueve trabajo a un worker. Cambia una librería de fechas de 90KB por una de 5KB. Reduce una hero sobredimensionada con el redimensionador de imágenes. Mide el impacto en datos reales, no en Lighthouse.
Después de un par de meses, el presupuesto te impide volver atrás y las métricas te avisan cuando algo empeora. Ese es todo el trabajo.
Para temas relacionados, revisa nuestra guía de formatos de imagen y la guía de INP.