Quedan tres filas de la línea base y ninguna de ellas se arregla escribiendo mejor JavaScript, porque todas ocurren antes de que se ejecute la primera línea de tu código: 4,1 s de LCP, 0,21 de CLS y 214 kB en 28 peticiones antes de que aparezca la primera tarjeta. Durante esos cuatro segundos, el índice Map de 09-02, la limpieza de fugas de 09-03 y la ventana virtual de 09-04 no existen todavía: Lucía mira una pantalla en blanco desde el móvil, en el tren, con Slow 4G. Esta lección trata de lo que decide ese tiempo: qué descarga el navegador, en qué orden, y cuánto de eso se usa de verdad. Verás la ruta crítica de renderizado y por qué el CSS bloquea mientras el JavaScript puede no hacerlo, cerrando lo que 01-03 y 06-01 dejaron apuntado sobre defer, async y type="module"; medirás el peso real con el panel Network y la pestaña Coverage; entenderás por fin qué hace un empaquetador y por qué existe, con una configuración mínima de Vite para Nómada Tareas; cerrarás la remisión que 05-04 dejó abierta sobre el tree shaking; dividirás el código con import() dinámico; aprenderás a precargar con preload, modulepreload, prefetch, preconnect y dns-prefetch; harás perezosas las imágenes y los componentes; arreglarás las fuentes, que es donde está la mitad del CLS; y cerrarás con la compresión, la caché HTTP y su relación con el service worker de 07-05. Al final, la tabla de las diez filas, completa, con sus columnas «antes» y «después».
Contenido
- El rendimiento que se decide antes de ejecutar una línea
- La ruta crítica de renderizado
- Por qué el CSS bloquea y el JavaScript puede no hacerlo
- Medir el peso real: el panel Network
- La pestaña Coverage: cuánto código descargado no se usa
- El problema de la fila 10: 28 módulos ES sin empaquetar
- Qué hace un empaquetador y por qué existe
- Vite para Nómada Tareas: configuración mínima y comentada
npm run build: qué genera y qué cambia enindex.html- Minificación
- Tree shaking: por qué los módulos ES lo hacen posible
- División de código con
import()dinámico - El patrón de carga: indicador, cancelación y fallo
- Precargar:
preload,modulepreload,prefetch,preconnect,dns-prefetch - Carga perezosa de imágenes
- Carga perezosa de componentes con
IntersectionObserver - Fuentes:
font-display, subconjuntos, precarga y métricas de respaldo - Cerrar la fila 3: de dónde salía el CLS de 0,21
- Compresión: Gzip y Brotli
- Caché HTTP, hashing y el service worker de 07-05
- El presupuesto de rendimiento en integración continua
- La tabla completa: las diez filas, antes y después
- Lo que no se ha resuelto y lo que ha costado
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- El rendimiento que se decide antes de ejecutar una línea
Hay una asimetría incómoda en el rendimiento web. Las tres lecciones anteriores han optimizado la ejecución: algoritmos, memoria, manipulación del DOM. Pero para que se ejecute algo, primero hay que descargarlo, analizarlo y compilarlo, y ese trabajo previo tiene sus propias reglas.
Mira la cronología real de Nómada Tareas en la línea base, con Slow 4G y CPU 4× (que es lo que Lighthouse simula por defecto y lo que Lucía tiene de verdad en el tren):
| Momento | t | Qué está pasando |
|---|---|---|
| Petición del documento | 0 ms | |
| Primer byte (TTFB) | 610 ms | El servidor responde |
| HTML analizado | 780 ms | Se descubren el CSS y el módulo de entrada |
| CSS descargado y aplicado | 1.240 ms | Hasta aquí, pantalla en blanco obligatoria |
| FCP | 2.300 ms | Aparece la cabecera |
| Los 28 módulos ES descargados | 3.150 ms | En cascada, por niveles del grafo |
app.js termina de ejecutarse |
3.790 ms | |
| Primera tarjeta pintada (LCP) | 4.100 ms |
Ninguno de esos milisegundos se arregla con un Map o con una ventana virtual. De los 4,1 segundos, tu código se ejecuta durante 310 (que 09-04 dejó en 31). Los otros 3,8 segundos son red, análisis y espera.
La consecuencia de método es la misma de 09-01, aplicada a otro eje: el cuello de botella se mueve. Optimizaste el render hasta hacerlo diez veces más rápido, y ahora el render es el 1 % del problema de la primera carga. Optimizar más el render no serviría de nada; lo que sirve es descargar menos y en mejor orden.
Y hay una segunda razón, menos evidente, por la que descargar menos código también acelera la ejecución. En 09-02 viste que el analizador de V8 es perezoso: no analiza a fondo el cuerpo de una función hasta que se va a ejecutar, pero sí tiene que recorrer el fichero entero para saber dónde termina cada función. Un módulo de 200 kB cuesta tiempo de análisis aunque solo uses una función de él. Reducir el JavaScript inicial no es solo una optimización de red: es también una optimización de CPU en el arranque, y por tanto de la fila 4 de la línea base.
- La ruta crítica de renderizado
La ruta crítica de renderizado (critical rendering path) es la secuencia mínima de recursos que el navegador necesita para pintar el primer píxel de contenido. Todo lo que está en esa ruta retrasa el FCP y, casi siempre, el LCP.
flowchart TD
A["Petición del documento"] --> B["HTML llega por trozos"]
B --> C["El analizador construye el DOM"]
C -->|"encuentra <link rel=stylesheet>"| D["Descargar CSS<br/><b>BLOQUEA el renderizado</b>"]
C -->|"encuentra <script> sin defer/module"| E["Descargar y ejecutar JS<br/><b>BLOQUEA el análisis</b>"]
C -->|"encuentra <script type=module>"| F["Descargar en paralelo<br/>ejecutar al terminar el DOM"]
D --> G["CSSOM completo"]
C --> H["DOM completo"]
G --> I["Árbol de renderizado"]
H --> I
I --> J["Layout → Pintado → FCP"]
F --> K["El JS muta el DOM"]
K --> L["LCP: aparece el contenido principal"]
J --> L
style D fill:#fdd,stroke:#c00
style E fill:#fdd,stroke:#c00
style F fill:#dfd,stroke:#090
Dos ideas hay que sacar de este diagrama, y son distintas entre sí:
El CSS bloquea el renderizado. El navegador no pinta absolutamente nada hasta tener todo el CSS que aplica a la página. La razón es de sentido común: si pintara antes, la página aparecería sin estilos y saltaría en cuanto llegaran, lo que se llama FOUC (flash of unstyled content) y produce un CLS espantoso. Así que el navegador prefiere el blanco a la mentira.
Un <script> clásico bloquea el análisis. Cuando el analizador encuentra un <script src> sin defer ni async, se detiene: descarga el fichero, lo ejecuta, y solo entonces sigue construyendo el DOM. Y como el script podría llamar a document.write, no hay forma de evitarlo. Ese es el motivo histórico de poner los scripts al final del <body>.
En Nómada Tareas hay un solo CSS (css/estilos.css, 18 kB) y un solo punto de entrada (<script type="module" src="js/app.js">). El CSS bloquea 460 ms con Slow 4G, y el módulo no bloquea el análisis pero sí arrastra la cascada de sus 27 dependencias.
- Por qué el CSS bloquea y el JavaScript puede no hacerlo
En 01-03 y 06-01 ya viste la tabla de defer, async y type="module". La recuperamos aquí, ampliada con lo que importa para la ruta crítica:
| Forma | ¿Bloquea el análisis del HTML? | Cuándo se ejecuta | ¿Orden garantizado? | Uso adecuado |
|---|---|---|---|---|
<script src> |
Sí, mientras descarga y ejecuta | Inmediatamente | Sí | Prácticamente nunca |
<script defer src> |
No | Tras analizar el documento, antes de DOMContentLoaded |
Sí | Código de la aplicación |
<script async src> |
No al descargar; sí al ejecutar | En cuanto se descargue | No | Scripts independientes (analítica) |
<script type="module"> |
No (es defer implícito) |
Tras analizar el documento | Sí | Lo que usa Nómada Tareas |
<script type="module" async> |
No al descargar; sí al ejecutar | En cuanto se descargue | No | Módulos sin dependencias del DOM |
Tres precisiones que evitan errores frecuentes:
async no es «mejor que defer». Es distinto. Un script async se ejecuta en cuanto llega, lo que significa que puede ejecutarse en mitad del análisis del HTML y bloquear el hilo principal en el peor momento posible. Y como no garantiza el orden, dos scripts async interdependientes fallan de forma intermitente. defer es la opción por defecto sensata; async solo para código que no depende de nada ni de nadie.
El CSS también se puede sacar de la ruta crítica. Un <link rel="stylesheet"> bloquea; pero se puede marcar como no bloqueante con el truco del media:
<!-- Bloqueante: los estilos que se necesitan para pintar lo que se ve al principio -->
<link rel="stylesheet" href="/assets/estilos-8f3a1c.css">
<!-- No bloqueante: estilos que solo hacen falta más tarde (impresión, modales) -->
<link rel="stylesheet" href="/assets/impresion-2b7e40.css" media="print">
<link rel="stylesheet" href="/assets/modales-9c1d5a.css" media="print" onload="this.media='all'">El segundo patrón funciona porque el navegador descarga con prioridad baja el CSS cuyo media no coincide, y no bloquea el renderizado por él; al cargar, el onload cambia el media a all y los estilos se aplican. Es un truco conocido y legítimo, pero conviene usarlo poco: en Nómada Tareas solo tiene sentido para el CSS de impresión.
El <style> en línea no bloquea la red pero sí ocupa bytes del HTML. La técnica del CSS crítico consiste en incrustar en el <head> los pocos estilos necesarios para pintar la parte visible y cargar el resto de forma no bloqueante. Es eficaz —en Nómada Tareas ahorra unos 300 ms de FCP— pero exige generar ese fragmento automáticamente en la compilación, porque mantenerlo a mano es garantía de que se quede obsoleto. Lo mencionamos como opción avanzada y no lo aplicaremos: con un CSS de 18 kB, el beneficio no compensa la complejidad.
- Medir el peso real: el panel Network
Ya usaste Network en 08-01 para depurar y en 09-01 para anotar la línea base. Aquí lo usamos como instrumento de medida, con el procedimiento fijo:
- Ventana de incógnito.
- Disable cache marcado. Sin esto mides tu segunda visita.
- Estrangulamiento:
Slow 4G. - Recargar y esperar a que termine todo.
- Mirar la barra de resumen del pie y ordenar por Size y por Time.
Las columnas que importan, ampliando la tabla de 09-01:
| Columna | Qué te dice | Señal de alarma |
|---|---|---|
| Size | Dos valores: transferido / recursos | Si coinciden, no hay compresión |
| Priority | Cómo prioriza el navegador | Tu imagen LCP en Low es un problema |
| Waterfall | Qué espera a qué | Escalones = cascada de dependencias |
| Initiator | Quién pidió el recurso | Encuentra importaciones inesperadas |
| Protocol | h2, h3, http/1.1 |
En HTTP/1.1, muchas peticiones sí duelen |
La barra de resumen de Nómada Tareas hoy, ya citada en 09-01:
Y el desglose por tipo, que es lo que dice dónde trabajar:
| Tipo | Peticiones | Transferido | Recursos | Comentario |
|---|---|---|---|---|
| Documento | 1 | 3,1 kB | 9,4 kB | |
| CSS | 1 | 4,8 kB | 18 kB | Comprimido, correcto |
| JavaScript | 28 | 214 kB | 214 kB | Sin comprimir, sin minificar |
| Fuentes | 1 | 94 kB | 94 kB | Una fuente completa |
| Imágenes | 2 | 41 kB | 41 kB | Sin dimensiones declaradas |
| Total | 31 | 238 kB | 512 kB |
Tres cosas saltan a la vista y las tres son de esta lección: 28 peticiones de JavaScript sin comprimir ni minificar, una fuente de 94 kB que resulta ser el segundo recurso más pesado de la página, y dos imágenes sin dimensiones, que es el clásico generador de CLS.
Un apunte sobre HTTP/2 y HTTP/3, porque circula una media verdad. Es cierto que con HTTP/2 muchas peticiones ya no cuestan lo que costaban en HTTP/1.1: hay multiplexación sobre una sola conexión y no existe el límite de seis peticiones en paralelo por dominio. Pero cada petición sigue teniendo un coste: cabeceras, latencia de ida y vuelta si no está en la ventana de congestión, y —lo importante para módulos ES— una cascada por niveles. Y esa cascada es exactamente el problema de la fila 10.
- La pestaña Coverage: cuánto código descargado no se usa
Hay una herramienta en DevTools que casi nadie abre y que responde a la pregunta más incómoda: de todo lo que has descargado, ¿cuánto se ha ejecutado?
Se abre desde el menú de comandos (Ctrl+Shift+P → Show Coverage), se pulsa el botón de recarga, y aparece una tabla con una barra por fichero: en rojo el código descargado y no usado, en azul el usado.
El procedimiento honesto tiene un matiz: Coverage mide hasta el momento en que paras la grabación. Si paras nada más cargar, verás un porcentaje de desuso altísimo que no significa «código muerto», sino «código todavía no ejecutado». La lectura correcta es:
- Parar justo tras el LCP para saber cuánto código sobra en la ruta crítica → eso es lo que hay que dividir.
- Parar tras usar la aplicación entera para saber cuánto código es realmente muerto → eso es lo que hay que borrar.
Resultado sobre Nómada Tareas, parando justo tras la primera tarjeta:
| Fichero | Bytes | No usado | % |
|---|---|---|---|
js/planificacion/informe.js |
34,1 kB | 34,1 kB | 100 % |
js/vista/editor-descripcion.js |
41,8 kB | 41,8 kB | 100 % |
js/util/formato.js |
12,4 kB | 7,9 kB | 64 % |
js/datos/api-tareas.js |
9,2 kB | 6,1 kB | 66 % |
js/vista/formulario.js |
11,7 kB | 9,4 kB | 80 % |
js/i18n/*.js (3 idiomas) |
12,3 kB | 8,2 kB | 67 % |
| Resto | 92,5 kB | 25,1 kB | 27 % |
| Total JavaScript | 214 kB | 132,6 kB | 62 % |
El 62 % del JavaScript que Lucía descarga en el tren no se ejecuta antes de ver la primera tarjeta. Y dos ficheros —el cálculo del informe de planificación y el editor enriquecido de descripciones— suman 76 kB al 100 % de desuso: son código que solo hace falta cuando alguien pulsa un botón que la mayoría de las visitas no pulsa nunca.
Ahí está el plan de la lección, y sale de una medición, no de una intuición:
- Lo que nunca se usa: borrarlo (tree shaking, apartado 11).
- Lo que se usa más tarde: cargarlo más tarde (división de código, apartado 12).
- Lo que se usa siempre: comprimirlo, minificarlo y cachearlo bien (apartados 10, 19 y 20).
- El problema de la fila 10: 28 módulos ES sin empaquetar
Hasta ahora, Nómada Tareas se ha servido tal cual: el navegador recibe app.js, ve sus import, pide esos ficheros, ve sus import, pide los siguientes… Es limpio, es exactamente lo que 05-04 enseñó, y en desarrollo es maravilloso. En producción tiene un problema concreto: la cascada.
flowchart TD
subgraph N1["Nivel 1 · 610 ms"]
A["app.js"]
end
subgraph N2["Nivel 2 · +280 ms"]
B["modelo/tablero.js"]
C["vista/tablero-vista.js"]
D["datos/repositorio-local.js"]
end
subgraph N3["Nivel 3 · +280 ms"]
E["modelo/tarea.js"]
F["vista/tarjeta.js"]
G["vista/dom.js"]
H["datos/http.js"]
end
subgraph N4["Nivel 4 · +280 ms"]
I["util/fechas.js"]
J["util/formato.js"]
K["util/tiempo.js"]
end
A --> B & C & D
B --> E
C --> F & G
D --> H
E --> I
F --> J
G --> K
El navegador no puede saber que necesita util/formato.js hasta que ha descargado y analizado vista/tarjeta.js, que a su vez necesitaba vista/tablero-vista.js, que necesitaba app.js. Con Slow 4G, cada nivel cuesta un viaje de ida y vuelta de unos 280 ms. Cuatro niveles son 1,1 segundos de espera pura, sin descargar apenas bytes.
Y además, los 28 ficheros llevan comentarios, nombres largos y espacios, porque son código fuente legible. Los 214 kB son, en buena parte, aire.
| Coste de servir módulos ES sin procesar | Magnitud |
|---|---|
| Cascada de descubrimiento (4 niveles × 280 ms) | 1.120 ms |
| Cabeceras y sobrecarga de 28 peticiones | ~95 ms |
| Bytes sobrantes (comentarios, nombres, formato) | ~96 kB |
| Sin compresión (servidor de desarrollo) | ~150 kB |
Esto no es un defecto de los módulos ES: es que fueron diseñados para expresar el grafo de dependencias, no para ser el formato de entrega óptimo. La herramienta que traduce lo uno en lo otro es el empaquetador.
- Qué hace un empaquetador y por qué existe
Un empaquetador (bundler) lee tu punto de entrada, recorre el grafo completo de import, y produce uno o varios ficheros optimizados para la entrega. Por el camino hace varias cosas distintas que conviene no confundir:
| Tarea | Qué hace | Qué resuelve |
|---|---|---|
| Empaquetado | Une módulos en pocos ficheros | La cascada y el número de peticiones |
| Resolución | Encuentra import 'web-vitals' en node_modules |
Los módulos ES del navegador no saben resolver bare imports |
| Minificación | Quita espacios, comentarios y acorta nombres locales | Bytes |
| Tree shaking | Elimina exportaciones que nadie importa | Bytes de código muerto |
| División de código | Separa lo que se carga bajo demanda | Bytes en la ruta crítica |
| Hashing | Renombra a index-8f3a1c.js según el contenido |
Caché eterna sin riesgo de servir lo viejo |
| Transformación | Compila TypeScript, JSX, CSS moderno | Compatibilidad |
| Activos | Convierte imágenes, fuentes y CSS en recursos con hash | Coherencia del despliegue |
De todas ellas, la que justifica por sí sola la existencia de la herramienta es la segunda. Esto, que en Node funciona desde siempre, no funciona en el navegador:
El navegador solo entiende rutas: ./x.js, /x.js, https://…. Un identificador desnudo como web-vitals no le dice nada. Existen los import maps para resolverlo a mano, pero en cuanto tienes cinco dependencias con sus propias dependencias, mantenerlos es inviable.
Y una aclaración importante, porque es fuente de confusión constante: empaquetar no significa «un solo fichero». Ese era el modelo de 2015, cuando HTTP/1.1 penalizaba mucho las peticiones. Hoy el objetivo es «pocos ficheros, bien elegidos»: un núcleo que siempre hace falta, y trozos separados para lo que solo hace falta a veces, cada uno con su propio hash para que la caché funcione con grano fino.
Panorama de herramientas, para situarse:
| Herramienta | Qué es | Cuándo elegirla |
|---|---|---|
| Vite | Servidor de desarrollo con módulos nativos + Rollup para producción | Por defecto hoy para una aplicación web |
| Rollup | Empaquetador orientado a ESM; el mejor tree shaking | Librerías |
| esbuild | Empaquetador y minificador en Go; extremadamente rápido | Cuando la velocidad manda |
| webpack | El veterano; el más configurable y el más complejo | Proyectos grandes heredados |
| Parcel | Cero configuración | Prototipos |
| Ninguno | Módulos ES nativos | Proyectos pequeños, demos, aprendizaje |
Elegimos Vite por un motivo que encaja con el módulo: en desarrollo no empaqueta nada, sirve los módulos ES nativos igual que hasta ahora (por eso arranca al instante y el recargado es inmediato), y solo empaqueta al compilar para producción. El código que has escrito en nueve módulos no cambia ni una línea.
- Vite para Nómada Tareas: configuración mínima y comentada
// vite.config.js
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
// Raíz del proyecto: donde está index.html. Vite parte del HTML, no del JS
root: '.',
// Ruta base con la que se generan las URLs de los activos.
// Si despliegas en https://taller.example/app/, aquí va '/app/'
base: '/',
build: {
outDir: 'dist', // carpeta de salida
assetsDir: 'assets', // dentro de dist/
emptyOutDir: true, // limpia dist/ antes de compilar
sourcemap: true, // mapas de origen: depurar producción (08-01)
target: 'es2022', // no transpilar de más: campos privados, groupBy…
cssCodeSplit: true, // un CSS por punto de entrada
// Avisar si un trozo supera el presupuesto (apartado 21)
chunkSizeWarningLimit: 80, // en kB
rollupOptions: {
output: {
// Nombres con hash de contenido: caché eterna sin riesgo (apartado 20)
entryFileNames: 'assets/[name]-[hash].js',
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash].[ext]',
/**
* Trozos manuales. Solo dos, y por una razón concreta:
* el modelo y las utilidades cambian mucho menos que la vista,
* así que separarlos hace que un cambio en la interfaz no invalide
* la caché de la parte estable.
*/
manualChunks(id) {
if (id.includes('/js/modelo/') || id.includes('/js/util/')) return 'modelo';
if (id.includes('node_modules/web-vitals')) return 'vitales';
return undefined; // lo demás, que lo decida Rollup
}
}
}
},
server: {
port: 5173,
open: true
},
preview: {
port: 4173 // el que usa Lighthouse CI en 09-01
},
// El informe visual del paquete: dist/informe-paquete.html
plugins: [
visualizer({
filename: 'dist/informe-paquete.html',
gzipSize: true,
brotliSize: true
})
]
});Cinco decisiones de ese fichero que no son cosméticas:
target: 'es2022'. Transpilar a ES5 «por si acaso» es hoy un error caro: engorda el paquete un 20–30 % y añade polyfills que ningún navegador con soporte de módulos ES necesita. Nómada Tareas usa campos privados (05-03) yObject.groupBy(06-06);es2022los conserva tal cual.sourcemap: true. Sin mapas de origen, un error en producción es una pila de llamadas ilegible con nombres de una letra. Los mapas se generan como ficheros aparte y no se descargan salvo que se abra DevTools, así que no cuestan nada al usuario. Súbelos, o al menos súbelos a tu servicio de errores.manualChunkscon criterio, no por costumbre. La razón para separarmodelono es el tamaño, es la frecuencia de cambio: la vista se toca cada semana y el modelo cada varios meses. Separarlos hace que un despliegue de la interfaz no obligue a los usuarios a volver a descargar el modelo.cssCodeSplit: truehace que cada punto de entrada tenga su CSS, y que el CSS de un trozo cargado dinámicamente se cargue con él.chunkSizeWarningLimit: 80es literalmente el presupuesto de la fila 10 de la línea base, puesto en la herramienta.
Y los guiones del package.json:
{
"name": "nomada-tareas",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "jest",
"test:e2e": "cypress run",
"lint": "eslint js test",
"analizar": "vite build && open dist/informe-paquete.html",
"presupuesto": "node scripts/presupuesto.mjs"
}
}
npm run build: qué genera y qué cambia en index.html
npm run build: qué genera y qué cambia en index.htmlvite v5.4.0 building for production... ✓ 34 modules transformed. dist/index.html 1,84 kB │ gzip: 0,79 kB dist/assets/estilos-4c9e21.css 17,90 kB │ gzip: 3,91 kB │ brotli: 3,22 kB dist/assets/vitales-9c1d5a.js 2,61 kB │ gzip: 1,18 kB │ brotli: 1,02 kB dist/assets/modelo-2b7e40.js 11,64 kB │ gzip: 4,02 kB │ brotli: 3,44 kB dist/assets/index-8f3a1c.js 44,08 kB │ gzip: 15,71 kB │ brotli: 14,93 kB dist/assets/informes-5a3f18.js 18,22 kB │ gzip: 6,44 kB │ brotli: 5,71 kB dist/assets/editor-7e1b93.js 24,36 kB │ gzip: 8,12 kB │ brotli: 7,05 kB dist/assets/planificador.worker-c40d7e.js 9,11 kB │ gzip: 3,28 kB dist/assets/ca-1f8a06.js 1,92 kB │ gzip: 0,74 kB dist/assets/en-4d2c77.js 1,88 kB │ gzip: 0,71 kB ✓ built in 1.42s
Lo primero que hay que aprender a leer en esa salida es qué está y qué no está en la ruta crítica. Los tres primeros ficheros JavaScript (index, modelo, vitales) los pide el HTML: son la ruta crítica. informes, editor, planificador.worker, ca y en no aparecen en el HTML: son trozos que solo se descargarán cuando alguien los pida, y eso es obra del apartado 12.
Y el index.html generado. Este es el de partida:
<!-- index.html — antes (fuente) -->
<link rel="stylesheet" href="/css/estilos.css">
<script type="module" src="/js/app.js"></script>Y esto es lo que Vite escribe en dist/index.html:
<!-- dist/index.html — generado -->
<link rel="stylesheet" crossorigin href="/assets/estilos-4c9e21.css">
<script type="module" crossorigin src="/assets/index-8f3a1c.js"></script>
<link rel="modulepreload" crossorigin href="/assets/modelo-2b7e40.js">
<link rel="modulepreload" crossorigin href="/assets/vitales-9c1d5a.js">Fíjate en lo que ha hecho sin que se lo pidiéramos: además de sustituir las rutas por las versiones con hash, ha añadido dos <link rel="modulepreload">. Eso elimina la cascada: el navegador descubre los tres ficheros JavaScript al analizar el <head>, y los pide en paralelo, sin esperar a analizar index-8f3a1c.js para descubrir que necesita modelo-2b7e40.js. Es el apartado 14, aplicado automáticamente.
El resultado sobre la fila 10, midiendo con Slow 4G en incógnito:
| Paso | JS antes de la 1.ª tarjeta | Peticiones JS | LCP |
|---|---|---|---|
| Línea base: 28 módulos ES sin procesar | 214 kB | 28 | 4,1 s |
Empaquetado sin minificar (build.minify: false) |
209 kB | 2 | 3,2 s |
| Con minificación (apartado 10) | 118 kB | 2 | 2,8 s |
| Con tree shaking (apartado 11) | 104 kB | 2 | 2,7 s |
| Con división de código (apartado 12) | 58,3 kB | 3 | 2,4 s |
Léelo con atención, porque cada fila enseña algo distinto. Empaquetar sin minificar apenas ahorra bytes (5 kB) pero recorta casi un segundo: lo que estaba matando el LCP no era el peso, era la cascada de 28 peticiones en cuatro niveles. La minificación es la que sí ahorra bytes, casi la mitad. Y la división de código quita otros 46 kB, que es lo que la pestaña Coverage había señalado como «descargado y no usado».
Un cambio de flujo de trabajo que conviene asumir explícitamente, porque tiene coste: a partir de ahora, la aplicación ya no se abre haciendo doble clic en index.html. Hay un paso de compilación. En desarrollo se usa npm run dev (que sirve módulos nativos, sin empaquetar, con recarga instantánea) y en producción npm run build + npm run preview. Volveremos sobre este coste en el apartado 23.
- Minificación
Minificar es reescribir el código para que ocupe menos sin cambiar lo que hace. Vite lo hace por defecto con esbuild. Concretamente:
| Transformación | Ejemplo |
|---|---|
| Quitar espacios y saltos de línea | Todo el fichero en pocas líneas |
| Quitar comentarios | Los JSDoc de tu código no llegan al usuario |
| Acortar nombres locales | const horasAbiertas → const a |
| Simplificar expresiones | if (x) { return 1 } else { return 2 } → return x?1:2 |
| Eliminar código inalcanzable | Lo que sigue a un return |
| Plegar constantes | 60 * 1000 → 60000 |
Un fragmento real, antes y después:
// Antes: js/modelo/tablero.js (fuente)
/**
* Resumen del tablero. Se recalcula solo si el tablero ha cambiado
* o si se pide para otra fecha.
*/
resumen(hoy = HOY) {
const c = this.#cacheResumen;
if (c !== null && c.version === this.#version && c.hoy === hoy) {
return c.valor;
}
const valor = this.#calcularResumen(hoy);
this.#cacheResumen = { version: this.#version, hoy, valor };
return valor;
}// Después: assets/modelo-2b7e40.js (minificado)
resumen(t=g){const e=this.#c;if(e!==null&&e.v===this.#v&&e.h===t)return e.r;const s=this.#p(t);return this.#c={v:this.#v,h:t,r:s},s}Tres cosas importantes sobre esto:
Los nombres exportados no se acortan (salvo con configuración avanzada), porque otro módulo puede importarlos por nombre. Solo se acortan los locales, que es donde está la mayor parte del texto.
Minificar no ofusca. Con los mapas de origen activados, DevTools te muestra el código original al depurar. La minificación no es una medida de seguridad y no debe usarse como tal.
Comprimir y minificar son cosas distintas y se suman. Minificar reduce el texto; comprimir (apartado 19) reduce los bytes que viajan por el cable. Los 44,08 kB del index minificado viajan como 14,93 kB con Brotli.
| Estado del fichero de entrada | Tamaño |
|---|---|
| Fuente, 28 ficheros | 214 kB |
| Empaquetado sin minificar | 209 kB |
| Empaquetado y minificado | 118 kB |
| Empaquetado, minificado y con tree shaking | 104 kB |
| Empaquetado, minificado, dividido | 58,3 kB |
| …y transferido con Brotli | 19,4 kB |
De 214 kB a 19,4 kB por el cable. Y ni una línea del código fuente ha cambiado todavía.
- Tree shaking: por qué los módulos ES lo hacen posible
En 05-04 se dijo: «el tree shaking y los empaquetadores se verán en 09-05». Ha llegado el momento.
Sacudir el árbol es eliminar del paquete final las exportaciones que nadie importa. El nombre viene de la imagen de agitar un árbol para que caigan las hojas muertas.
Lo interesante es por qué se puede hacer, y la respuesta enlaza con algo que 05-04 explicó por otro motivo: los import y export de los módulos ES son estáticos. Deben estar en el nivel superior, no pueden ir dentro de un if ni de una función, y sus nombres son literales. Esa restricción, que en su momento pudo parecer un fastidio, es exactamente lo que permite a una herramienta saber con certeza, sin ejecutar nada, qué exportaciones se usan y cuáles no.
Con CommonJS eso es imposible:
// CommonJS: la herramienta no puede saber qué se importa hasta ejecutarlo
const nombre = condicion ? 'formatearFecha' : 'formatearHoras';
const fn = require('./util/formato.js')[nombre]; // ✗ indecidible estáticamente// ESM: los nombres son literales y están en el nivel superior. Decidible
import { formatearFecha } from './util/formato.js'; // ✓ solo esto se conservaEl caso concreto en Nómada Tareas es util/formato.js, que desde 07-06 crea nueve formateadores de Intl:
// js/util/formato.js
const LOCAL = 'es-ES';
const FECHA_LARGA = new Intl.DateTimeFormat(LOCAL, { dateStyle: 'long' });
const FECHA_CORTA = new Intl.DateTimeFormat(LOCAL, { day: 'numeric', month: 'short' });
const FECHA_HORA = new Intl.DateTimeFormat(LOCAL, { dateStyle: 'medium', timeStyle: 'short' });
const RELATIVO = new Intl.RelativeTimeFormat(LOCAL, { numeric: 'auto' });
const NUMERO = new Intl.NumberFormat(LOCAL, { maximumFractionDigits: 1 });
const HORAS = new Intl.NumberFormat(LOCAL, { style: 'unit', unit: 'hour', unitDisplay: 'long' });
const PORCENTAJE = new Intl.NumberFormat(LOCAL, { style: 'percent', maximumFractionDigits: 0 });
const LISTA = new Intl.ListFormat(LOCAL, { style: 'long', type: 'conjunction' });
const COMPARADOR = new Intl.Collator(LOCAL, { sensitivity: 'base', numeric: true });
export function fechaLegible(iso) { /* usa FECHA_LARGA */ }
export function fechaCorta(iso) { /* usa FECHA_CORTA */ }
export function fechaYHora(iso) { /* usa FECHA_HORA */ }
export function hace(dias) { /* usa RELATIVO */ }
export function numero(n) { /* usa NUMERO */ }
export function horasLegibles(n) { /* usa HORAS */ }
export function porcentaje(n) { /* usa PORCENTAJE */ }
export function listaLegible(items) { /* usa LISTA */ }
export function compararTextos(a, b) { /* usa COMPARADOR */ }De esas nueve funciones, la ruta crítica usa tres: fechaCorta en .tarea__meta, horasLegibles en el contador de columna y compararTextos en el orden por título. Las otras seis las usan el informe y el exportador, que ya no están en la ruta crítica. El tree shaking elimina esas seis funciones y sus formateadores, y ahí está buena parte de los 14 kB que se pierden en ese paso.
Ahora, las cuatro condiciones para que el tree shaking funcione de verdad, porque falla mucho más a menudo de lo que la gente cree:
1 · Nada de importaciones con comodín cuando puedas evitarlo.
// ✗ Impide sacudir: pide el objeto entero
import * as formato from './util/formato.js';
elemento.textContent = formato.fechaCorta(t.fechaLimite);
// ✓ Importación nominal: la herramienta sabe exactamente qué se usa
import { fechaCorta } from './util/formato.js';En la práctica, Rollup es lo bastante listo para sacudir muchos import * cuando el uso es analizable, pero basta que pases formato a otra función para que tenga que conservarlo entero. La importación nominal nunca falla.
2 · Cuidado con los efectos secundarios de módulo. Si un módulo hace algo al importarse —registrar un manejador, mutar un global, crear un objeto caro—, el empaquetador no puede eliminarlo aunque no uses ninguna de sus exportaciones, porque no sabe si ese efecto importa.
// ✗ Efecto secundario al importar: este módulo nunca se podrá eliminar
document.body.classList.add('con-js');
export function nada() {}La forma de declarar que tus módulos son limpios es el campo sideEffects del package.json:
Eso dice: «todo mi código es libre de efectos secundarios salvo los CSS y el punto de entrada». Sin esa declaración, muchas herramientas asumen lo peor y conservan de más.
3 · La librería tiene que estar publicada como ESM. Una dependencia que solo ofrece CommonJS no se puede sacudir. web-vitals publica ESM, por eso sus 2,6 kB son solo lo que usas de él. Es un criterio real a la hora de elegir dependencias.
4 · El código muerto tiene que ser realmente inalcanzable. Si una función se usa en una rama que solo se ejecuta en desarrollo, sigue estando referenciada. Para eso están las constantes de compilación:
// Vite sustituye import.meta.env.DEV por `false` al compilar,
// y entonces el minificador elimina el bloque entero como código muerto
if (import.meta.env.DEV) {
const { instrumentar } = await import('./util/medicion.js');
instrumentar(vista);
}Y una forma de comprobar que todo esto funciona en lugar de suponerlo: el informe de rollup-plugin-visualizer que configuramos en el apartado 8.
Abre un mapa de árbol donde el tamaño de cada rectángulo es el peso del módulo en el paquete final. Es la herramienta que responde a «¿por qué mi paquete pesa 140 kB?», y la respuesta suele ser una dependencia que no sabías que estabas arrastrando.
- División de código con
import() dinámico
import() dinámicoEn 05-04 conociste import() dinámico: se parece a una llamada a función, devuelve una promesa, admite rutas variables y puede ir dentro de un if. Allí se presentó como sintaxis; aquí es la herramienta que quita 46 kB de la ruta crítica.
La regla para decidir qué dividir sale directamente de la tabla de Coverage del apartado 5:
Divide lo que cumpla las tres condiciones: (1) pesa, (2) no se usa en la primera pantalla y (3) se activa por una acción concreta del usuario. Si falta cualquiera de las tres, no lo dividas: añadirás una espera sin ahorrar nada relevante.
En Nómada Tareas hay exactamente tres candidatos.
12.1 El informe de planificación y su Web Worker
El módulo de informes pesa 18,2 kB minificado y arrastra el worker de 09-02. Lo usa Marta una vez al trimestre.
// js/vista/controlador.js
// ✗ Antes: importación estática. 18 kB y el worker en la ruta crítica
// import { Planificador } from '../planificacion/cliente-planificador.js';
let planificador = null;
$('#generar-informe').addEventListener('click', async () => {
const boton = $('#generar-informe');
boton.disabled = true;
boton.textContent = 'Cargando…';
try {
// ✓ Se descarga la PRIMERA vez que alguien pulsa. Después ya está en memoria
if (planificador === null) {
const { Planificador } = await import('../planificacion/cliente-planificador.js');
planificador = new Planificador();
}
boton.textContent = 'Calculando…';
const informe = await planificador.informe([...tablero].map((t) => t.toJSON()));
mostrarInforme(informe);
} catch (error) {
mostrarError(`No se ha podido generar el informe: ${error.message}`);
} finally {
boton.disabled = false;
boton.textContent = 'Generar informe';
}
}, { signal: controlador.signal });Fíjate en un detalle que Vite resuelve solo: el Planificador crea su worker con new Worker(new URL('./planificador.worker.js', import.meta.url), { type: 'module' }). Ese patrón, que en 09-02 se presentó como «obligatorio si usas un empaquetador», es exactamente lo que permite a Vite descubrir el worker, compilarlo como un trozo aparte con su hash (planificador.worker-c40d7e.js) y reescribir la URL. Con una ruta en cadena de texto, el worker no se habría incluido en la compilación y en producción daría un 404.
12.2 El editor enriquecido de descripciones
Pesa 24,4 kB y solo aparece cuando Iván pulsa «Editar descripción» en una tarjeta. Es el caso de libro.
// js/vista/formulario.js
import { $ } from './dom.js';
/** Carga el editor la primera vez que hace falta. Cachea la promesa, no el módulo. */
const cargarEditor = (() => {
let promesa = null;
return () => (promesa ??= import('./editor-descripcion.js'));
})();
$('#nueva-tarea').addEventListener('click', async (evento) => {
if (evento.target.closest('[data-accion="editar-descripcion"]') === null) return;
const caja = $('#descripcion');
caja.classList.add('cargando');
try {
const { montarEditor } = await cargarEditor();
montarEditor(caja, { alGuardar: guardarDescripcion });
} catch (error) {
// Degradación elegante: el <textarea> normal sigue funcionando
console.warn('No se ha podido cargar el editor enriquecido', error);
caja.focus();
} finally {
caja.classList.remove('cargando');
}
}, { signal: controlador.signal });Dos decisiones interesantes en ese fragmento:
- Se cachea la promesa, no el módulo.
promesa ??= import(...)garantiza que dos clics rápidos no lancen dos descargas: el segundo recibe la misma promesa en vuelo. Es un patrón que conviene tener automatizado. - El fallo tiene una salida digna. Si el trozo no se descarga —red caída, despliegue a mitad— el
<textarea>normal sigue ahí y el usuario puede escribir. Eso es mejora progresiva, el mismo principio de 07-06.
12.3 Las traducciones
Cada idioma pesa unos 1,9 kB. Descargar los tres para usar uno es tirar dos tercios, y es el caso donde la ruta variable de import() brilla:
// js/i18n/index.js
const IDIOMAS = new Set(['es', 'ca', 'en']);
const cargados = new Map();
/**
* Carga los textos de un idioma bajo demanda.
* El literal parcial de la plantilla es IMPRESCINDIBLE: le dice al empaquetador
* qué ficheros son candidatos, y por eso genera ca-*.js y en-*.js como trozos.
*/
export async function cargarIdioma(codigo) {
if (!IDIOMAS.has(codigo)) codigo = 'es';
if (cargados.has(codigo)) return cargados.get(codigo);
const promesa = import(`./textos/${codigo}.js`).then((m) => m.textos);
cargados.set(codigo, promesa);
return promesa;
}El detalle que hay que entender es el del comentario: import(variable) a secas es indecidible para el empaquetador, que no sabría qué incluir y fallaría en tiempo de ejecución. import(\./textos/${codigo}.js`)`, con la parte fija visible en el literal, le permite compilar todos los ficheros que encajen con el patrón como trozos independientes, y elegir uno en tiempo de ejecución. Es una restricción que hay que conocer.
Además, el español se resuelve sin descarga porque es el idioma por defecto y sus textos están en el paquete principal. Solo Marta, que tiene el navegador en catalán, descarga 1,9 kB extra.
Resultado de las tres divisiones:
| Trozo | Tamaño | Cuándo se descarga | % de visitas que lo piden |
|---|---|---|---|
index-8f3a1c.js |
44,1 kB | Siempre | 100 % |
modelo-2b7e40.js |
11,6 kB | Siempre | 100 % |
vitales-9c1d5a.js |
2,6 kB | Siempre | 100 % |
informes-5a3f18.js + worker |
27,3 kB | Al pulsar «Generar informe» | 4 % |
editor-7e1b93.js |
24,4 kB | Al editar una descripción | 11 % |
ca-1f8a06.js / en-4d2c77.js |
1,9 kB | Según el idioma | 22 % |
58,3 kB para el 100 % de las visitas, en lugar de 104 kB. Y el ahorro no se reparte por igual: quien solo consulta el tablero —la mayoría— descarga 58 kB y ya está.
Y un aviso contra el entusiasmo, porque dividir de más es un error real: cada import() es un viaje de ida y vuelta. Si divides un módulo de 3 kB que se necesita medio segundo después de cargar, has cambiado 3 kB por 280 ms de latencia en Slow 4G. Eso es un mal negocio. Divide trozos gordos y tardíos, no todo lo que se pueda dividir.
- El patrón de carga: indicador, cancelación y fallo
Cargar bajo demanda introduce algo que antes no existía: una espera visible en mitad de una interacción. Hay que tratarla como cualquier operación asíncrona (05-06, 07-03), y conviene tener un ayudante único para no repetir el mismo cableado.
// js/util/perezoso.js
/**
* Envuelve un import() dinámico con caché, indicador y reintento.
*
* @param {() => Promise<any>} cargador Función que hace el import().
* @param {object} opciones
* @param {HTMLElement} [opciones.indicador] Elemento al que poner la clase 'cargando'.
* @param {number} [opciones.reintentos] Reintentos ante fallo de red.
* @returns {() => Promise<any>}
*/
export function perezoso(cargador, { indicador = null, reintentos = 1 } = {}) {
let promesa = null;
return async function cargar() {
if (promesa !== null) return promesa; // ya cargado o en vuelo
indicador?.classList.add('cargando');
indicador?.setAttribute('aria-busy', 'true'); // accesibilidad: hay algo en marcha
promesa = (async () => {
for (let intento = 0; ; intento += 1) {
try {
return await cargador();
} catch (error) {
if (intento >= reintentos) {
promesa = null; // ← permitir volver a intentarlo luego
throw error;
}
// Retroceso simple, como el conReintentos de 07-03
await new Promise((r) => setTimeout(r, 400 * (intento + 1)));
}
}
})();
try {
return await promesa;
} finally {
indicador?.classList.remove('cargando');
indicador?.removeAttribute('aria-busy');
}
};
}// Uso
const cargarInformes = perezoso(
() => import('../planificacion/cliente-planificador.js'),
{ indicador: $('#panel-informe'), reintentos: 2 }
);Tres detalles que separan una carga perezosa correcta de una que da problemas en producción:
Al fallar definitivamente hay que borrar la promesa cacheada. Si la conservas, todos los intentos posteriores recibirán el mismo error para siempre, aunque la red haya vuelto. Es un fallo sutil y muy común.
Un despliegue nuevo invalida los hashes. Si un usuario tiene la pestaña abierta desde hace dos horas y desplegáis una versión nueva, el editor-7e1b93.js que su index intenta pedir puede ya no existir en el servidor. Ese import() fallará con un error de red que parece inexplicable. Hay dos mitigaciones: conservar los ficheros antiguos en el servidor durante unos días (lo más sencillo y lo que hace la mayoría), y detectar el fallo para ofrecer recargar:
window.addEventListener('vite:preloadError', (evento) => {
evento.preventDefault();
mostrarAviso('Hay una versión nueva de Nómada Tareas. Recarga para continuar.', {
accion: () => location.reload()
});
});El indicador debe ser accesible. aria-busy="true" avisa a los lectores de pantalla de que la región está actualizándose; una clase CSS con una animación no le dice nada a nadie que no vea la pantalla. Y si la espera puede superar el segundo (09-01), hace falta además un texto explícito.
- Precargar:
preload, modulepreload, prefetch, preconnect, dns-prefetch
preload, modulepreload, prefetch, preconnect, dns-prefetchEl navegador es bastante bueno priorizando, pero solo puede priorizar lo que conoce. Las sugerencias de recurso (resource hints) sirven para contarle cosas antes de que las descubra por sí mismo.
| Sugerencia | Qué hace | Prioridad | Cuándo usarla | Riesgo de abusar |
|---|---|---|---|---|
<link rel="preconnect"> |
Abre la conexión (DNS + TCP + TLS) sin descargar nada | — | Un origen del que seguro vas a pedir algo enseguida | Cada conexión ociosa consume recursos. Máximo 2–3 |
<link rel="dns-prefetch"> |
Solo resuelve el DNS | — | Orígenes probables pero no seguros | Casi ninguno; es muy barato |
<link rel="preload"> |
Descarga ya, con prioridad alta, sin ejecutar | Alta | Un recurso crítico que el navegador descubriría tarde (fuentes, imagen LCP) | Compite con lo que sí es crítico y puede empeorar el LCP |
<link rel="modulepreload"> |
Descarga y analiza un módulo ES, sin ejecutarlo | Alta | Trozos que el módulo de entrada va a importar | El análisis también cuesta CPU |
<link rel="prefetch"> |
Descarga con prioridad mínima, para la navegación siguiente | La más baja | Lo que el usuario probablemente pedirá después | Gasta datos que quizá no se usen |
La diferencia entre preload y prefetch es la que más se confunde y es fácil de recordar: preload es «lo necesito para esta pantalla, ahora»; prefetch es «puede que lo necesite después, cuando no tengas nada mejor que hacer».
Aplicado a Nómada Tareas:
<head>
<!-- 1 · La API de tareas: conexión abierta antes de que app.js haga el primer fetch -->
<link rel="preconnect" href="https://api.tallernomada.example" crossorigin>
<link rel="dns-prefetch" href="https://api.tallernomada.example">
<!-- 2 · La fuente: el navegador no la descubre hasta analizar el CSS. Sin esto,
llega tarde y provoca el salto de texto que verás en el apartado 17.
`crossorigin` es OBLIGATORIO en las fuentes, aunque sean del mismo origen -->
<link rel="preload" href="/assets/inter-subset-3d9a17.woff2"
as="font" type="font/woff2" crossorigin>
<!-- 3 · Generados por Vite: los trozos que index-*.js va a importar -->
<link rel="modulepreload" crossorigin href="/assets/modelo-2b7e40.js">
<link rel="modulepreload" crossorigin href="/assets/vitales-9c1d5a.js">
<link rel="stylesheet" crossorigin href="/assets/estilos-4c9e21.css">
<script type="module" crossorigin src="/assets/index-8f3a1c.js"></script>
</head>Y el prefetch, que en Nómada Tareas se hace mejor desde JavaScript, cuando el navegador está ocioso (09-02):
// js/app.js — precargar el editor cuando no haya nada mejor que hacer
requestIdleCallback(() => {
// El editor lo usa el 11 % de las visitas, pero cuando se usa, se usa pronto
import('./vista/editor-descripcion.js');
}, { timeout: 5000 });Ese import() sin await y sin usar el resultado es idiomático: solo sirve para que el trozo entre en la caché HTTP. Cuando el usuario pulse «Editar descripción», el módulo ya estará ahí y la carga será instantánea.
Y las tres reglas que evitan que las sugerencias hagan daño:
- Precarga poco. Si precargas cinco cosas, ninguna es prioritaria.
preloadfunciona porque desplaza recursos en la cola; precargarlo todo es no precargar nada. - Nunca precargues algo que no vayas a usar en esta pantalla. El navegador avisa en la consola («The resource was preloaded but not used within a few seconds») y con razón: has gastado ancho de banda de la ruta crítica.
crossoriginen las fuentes es obligatorio. Las fuentes se descargan siempre en modo anónimo, así que unpreloadsincrossoriginprovoca dos descargas de la misma fuente. Es el error más común conpreloady duplica el peso del recurso más caro de la página.
- Carga perezosa de imágenes
Nómada Tareas tiene pocas imágenes —el logotipo del taller y el avatar de cada responsable— pero las suficientes para ilustrar los cuatro atributos que importan.
<!-- ✗ Antes: sin dimensiones, sin prioridad, todo eager -->
<img src="/img/logo-taller.png" alt="Taller Nómada">
<img src="/img/avatar-ivan.png" alt="">
<!-- ✓ Después -->
<img src="/assets/logo-taller-a91c4e.webp"
alt="Taller Nómada"
width="180" height="48"
fetchpriority="high"
decoding="async">
<img src="/assets/avatar-ivan-6b2d05.webp"
alt=""
width="32" height="32"
loading="lazy"
decoding="async">Qué hace cada atributo:
| Atributo | Qué hace | Cuándo |
|---|---|---|
width / height |
Reservan el espacio antes de que la imagen llegue | Siempre. Es la mitad del CLS |
loading="lazy" |
No se descarga hasta acercarse al área visible | Imágenes fuera de la primera pantalla |
loading="eager" |
Descarga inmediata (valor por defecto) | La imagen LCP |
decoding="async" |
Decodificar fuera del hilo principal | Casi siempre |
fetchpriority="high" |
Sube la prioridad en la cola de descarga | La imagen LCP, y solo ella |
fetchpriority="low" |
La baja | Imágenes decorativas grandes |
Cuatro avisos que evitan los errores típicos:
loading="lazy" en la imagen LCP es un tiro en el pie. Retrasa deliberadamente el recurso que define tu métrica principal. Es, con diferencia, el mal uso más frecuente de esta técnica, y Lighthouse lo detecta y lo señala.
width y height funcionan aunque el CSS cambie el tamaño. Poner width="180" height="48" no fija el tamaño si tienes img { max-width: 100%; height: auto; }: los navegadores modernos usan esos dos números solo para calcular la relación de aspecto y reservar el hueco correcto. Es exactamente lo que hace falta para el CLS y no interfiere con el diseño adaptable.
El formato importa más que la carga perezosa. Convertir los PNG a WebP bajó las dos imágenes de Nómada Tareas de 41 kB a 9,2 kB sin diferencia visible. AVIF habría bajado a 6,8 kB con menos compatibilidad. Antes de discutir sobre lazy, comprueba el formato.
Para imágenes grandes, usa srcset y sizes. Servir una imagen de 1.600 px de ancho a un móvil de 360 px es tirar tres cuartas partes de los bytes:
<img src="/assets/portada-800.webp"
srcset="/assets/portada-400.webp 400w,
/assets/portada-800.webp 800w,
/assets/portada-1600.webp 1600w"
sizes="(max-width: 640px) 100vw, 800px"
width="1600" height="900"
alt="Vista del taller" fetchpriority="high" decoding="async">
- Carga perezosa de componentes con
IntersectionObserver
IntersectionObserverloading="lazy" solo existe para <img> e <iframe>. Para componentes —un mapa, un gráfico, un panel pesado— hace falta el IntersectionObserver de 07-06, combinado con el import() del apartado 12.
En Nómada Tareas hay un candidato claro: el panel de carga por responsable, que dibuja un gráfico de barras y vive al final de la barra lateral, casi siempre fuera de la pantalla.
// js/vista/perezoso-visible.js
/**
* Monta un componente la primera vez que su contenedor se acerca al área visible.
*
* @param {HTMLElement} contenedor
* @param {() => Promise<{montar: Function}>} cargador
* @param {object} [opciones]
* @returns {() => void} función de limpieza (09-03)
*/
export function alAcercarse(contenedor, cargador, { margen = '300px' } = {}) {
const observador = new IntersectionObserver(async (entradas) => {
if (!entradas[0].isIntersecting) return;
observador.disconnect(); // una sola vez: nada de recargar al hacer scroll
contenedor.setAttribute('aria-busy', 'true');
try {
const { montar } = await cargador();
montar(contenedor);
} catch (error) {
contenedor.textContent = 'No se ha podido cargar el panel de carga.';
console.warn(error);
} finally {
contenedor.removeAttribute('aria-busy');
}
}, { rootMargin: margen }); // empezar ANTES de que se vea (07-06)
observador.observe(contenedor);
return () => observador.disconnect(); // para el destruir() de la vista
}// js/app.js
import { alAcercarse } from './vista/perezoso-visible.js';
const limpiarPanel = alAcercarse(
$('#panel-carga'),
() => import('./vista/panel-carga.js')
);Dos condiciones para que esto no empeore la experiencia, y ambas son de la lección anterior:
El hueco debe estar reservado. Si #panel-carga mide 0 px hasta que el componente se monta, al montarse empujará todo hacia abajo y sumará CLS. La solución es darle en el CSS la altura que va a tener, o aspect-ratio:
El rootMargin debe ser generoso. Con 300px, la descarga empieza cuando el panel está a trescientos píxeles de asomar, así que para cuando el usuario llega, ya está montado. Sin margen, vería el hueco vacío durante medio segundo.
- Fuentes:
font-display, subconjuntos, precarga y métricas de respaldo
font-display, subconjuntos, precarga y métricas de respaldoLas fuentes web son, con diferencia, el recurso más subestimado. En Nómada Tareas, una sola fuente pesa 94 kB: más que todo el JavaScript de la ruta crítica después de optimizarlo. Y además es la principal fuente de CLS.
El problema tiene dos caras que hay que separar:
- FOIT (flash of invisible text): mientras la fuente se descarga, el navegador oculta el texto. La página se ve vacía aunque el HTML esté listo. Mata el FCP y el LCP.
- FOUT (flash of unstyled text): se muestra el texto con una fuente de respaldo y se cambia al llegar la web. No mata el LCP, pero si las dos fuentes tienen métricas distintas, el texto se recoloca y eso es CLS.
17.1 font-display
@font-face {
font-family: 'Inter';
src: url('/assets/inter-subset-3d9a17.woff2') format('woff2');
font-weight: 400 700; /* fuente variable: un fichero, todos los grosores */
font-style: normal;
font-display: swap; /* ← la decisión clave */
}Valor de font-display |
Periodo de bloqueo | Comportamiento |
|---|---|---|
auto |
~3 s | Lo que decida el navegador. Suele ser FOIT largo |
block |
~3 s | Texto invisible hasta 3 s. Evítalo |
swap |
0 ms | Respaldo inmediato, cambio al llegar. FOUT, no FOIT |
fallback |
~100 ms | Breve invisibilidad; si tarda más de 3 s, se queda el respaldo |
optional |
~100 ms | Como fallback, pero el navegador puede no usar la web nunca. Cero CLS |
swap es la elección por defecto sensata: garantiza que el texto se ve desde el primer momento. optional es la elección si el CLS te importa más que la tipografía —el navegador decide, y en conexiones lentas simplemente usa el respaldo, con salto cero—.
17.2 Subconjuntos
Una fuente completa incluye miles de glifos: griego, cirílico, vietnamita, símbolos matemáticos. Nómada Tareas escribe en español, catalán e inglés: necesita latín básico y extendido, y poco más.
# La herramienta estándar (Python), del proyecto fonttools
pip install fonttools brotli
pyftsubset inter-variable.ttf \
--output-file=inter-subset.woff2 \
--flavor=woff2 \
--layout-features='kern,liga' \
--unicodes='U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212'| Fuente | Tamaño | Glifos |
|---|---|---|
inter-variable.ttf (original) |
312 kB | 2.548 |
inter-variable.woff2 |
94 kB | 2.548 |
inter-subset.woff2 |
21,4 kB | 382 |
De 94 kB a 21,4 kB. Es el mayor ahorro individual de toda la lección, y no toca ni una línea de código. Tres reglas: usa siempre WOFF2 (los formatos .ttf, .eot y .woff sobran desde hace años), usa fuentes variables cuando necesites varios grosores, y comprueba que el subconjunto incluye lo que escribes: los nombres catalanes con ŀ, las comillas tipográficas y el símbolo · de los separadores de Nómada Tareas.
17.3 Precarga
El navegador no descubre la fuente hasta que ha descargado el CSS y ha calculado qué elementos la usan. Eso son dos viajes de ida y vuelta de retraso. Por eso la fuente crítica se precarga, con el crossorigin del apartado 14:
<link rel="preload" href="/assets/inter-subset-3d9a17.woff2"
as="font" type="font/woff2" crossorigin>Precarga una sola fuente: la que se usa para el texto principal. Precargar cuatro variantes es un caso de manual de sugerencia contraproducente.
17.4 Métricas de respaldo: el CLS que queda
Aun con swap y precarga, queda el salto del cambio de fuente: la de respaldo y la web tienen anchos y alturas de línea distintos, así que al intercambiarlas el texto se recoloca. La solución moderna es declarar una fuente de respaldo ajustada con las métricas de la real:
/* Respaldo ajustado: la fuente del sistema, deformada para MEDIR igual que Inter */
@font-face {
font-family: 'Inter respaldo';
src: local('Arial'), local('Helvetica Neue'), local('sans-serif');
size-adjust: 107%; /* Arial es más estrecha: se ensancha un 7 % */
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: 'Inter', 'Inter respaldo', system-ui, sans-serif;
}Con esas cuatro propiedades, el texto de respaldo ocupa exactamente las mismas cajas que el definitivo, y el cambio deja de mover nada: el FOUT sigue existiendo visualmente (cambia la forma de las letras) pero el CLS cae a cero. Los valores de size-adjust y compañía se calculan comparando las métricas de las dos fuentes; hay herramientas que los generan, y no es razonable ajustarlos a ojo.
- Cerrar la fila 3: de dónde salía el CLS de 0,21
El CLS mide cuánto se mueve el contenido sin que el usuario lo provoque. En 09-01 anotamos 0,21 y en 09-04 aprendimos a verlo con Layout Shift Regions del panel Rendering. Grabando con el panel Performance y mirando las entradas layout-shift, el 0,21 se descompone así:
| Origen del salto | Aportación | Cuándo ocurre |
|---|---|---|
| Las 600 tarjetas se insertan al llegar los datos y empujan el pie | 0,13 | ~3,8 s |
| El texto se recoloca al llegar la fuente web | 0,05 | ~2,9 s |
El logotipo sin width/height reserva 0 px y luego 48 px |
0,03 | ~1,3 s |
| Total | 0,21 |
Y las tres correcciones, cada una de un apartado distinto de esta lección:
El salto de las tarjetas (0,13 → 0,00). No se arregla cargando antes, sino reservando el sitio: un esqueleto con la altura exacta que van a tener las columnas. Como la lista está virtualizada (09-04) y cada tarjeta mide 116 px, la altura es conocida de antemano.
.columna { min-height: 640px; } /* el hueco existe desde el primer pintado */
.lista-tareas { min-height: 640px; }
.tarea--esqueleto {
height: 108px;
margin-bottom: 8px;
background: linear-gradient(90deg, var(--gris-claro) 25%, var(--gris-muy-claro) 50%,
var(--gris-claro) 75%);
background-size: 200% 100%;
animation: brillo 1.4s infinite;
}
@keyframes brillo { to { background-position: -200% 0; } } /* solo background-position */
@media (prefers-reduced-motion: reduce) {
.tarea--esqueleto { animation: none; }
}Una nota que enlaza con 09-01: el esqueleto no cuenta como contenido para el LCP. Sirve para el CLS y para la percepción, no para la métrica. No lo pongas creyendo que mejora el LCP, porque no lo hace.
El salto de la fuente (0,05 → 0,00). El respaldo ajustado con size-adjust y ascent-override del apartado 17.4.
El salto del logotipo (0,03 → 0,00). width="180" height="48" en el <img>, del apartado 15.
| Medida | Antes | Después |
|---|---|---|
| CLS (Lighthouse móvil) | 0,21 | 0,02 |
El 0,02 restante es un salto minúsculo de la barra de resumen, que pasa de una línea a dos cuando el número de tareas supera tres cifras. Está por debajo del umbral de 0,1 y arreglarlo exigiría fijar la altura de un elemento cuyo contenido es genuinamente variable. Es una decisión consciente de no perseguir el cero, en la línea de 09-01: los últimos puntos cuestan mucho y no aportan nada perceptible.
- Compresión: Gzip y Brotli
Minificar reduce el texto; comprimir reduce los bytes que viajan. Se suman, y la compresión es responsabilidad del servidor, no del empaquetador.
| Algoritmo | Compatibilidad | Ratio en JS | Coste de comprimir |
|---|---|---|---|
| Ninguno | — | 1× | 0 |
| Gzip | Universal desde hace 20 años | ~3,5× | Bajo |
Brotli (br) |
Todos los navegadores modernos | ~4,2× | Medio (alto si es al vuelo) |
Zstandard (zstd) |
Emergente | ~4,3× | Bajo |
El navegador anuncia lo que acepta y el servidor elige:
Accept-Encoding: gzip, deflate, br, zstd ← lo que envía el navegador Content-Encoding: br ← lo que responde el servidor
Sobre Nómada Tareas ya compilada:
| Fichero | Sin comprimir | Gzip | Brotli |
|---|---|---|---|
index-8f3a1c.js |
44,08 kB | 15,71 kB | 14,93 kB |
modelo-2b7e40.js |
11,64 kB | 4,02 kB | 3,44 kB |
vitales-9c1d5a.js |
2,61 kB | 1,18 kB | 1,02 kB |
estilos-4c9e21.css |
17,90 kB | 3,91 kB | 3,22 kB |
| Total ruta crítica | 76,23 kB | 24,82 kB | 22,61 kB |
Tres cosas que hay que saber:
Comprime en la compilación, no al vuelo. Brotli con nivel máximo (11) es lento de comprimir y rápido de descomprimir. Comprimir cada respuesta al vuelo obliga a usar un nivel bajo; generar los .br durante la compilación permite el nivel 11 gratis. Casi cualquier servidor sabe servir un fichero.js.br precomprimido si existe.
No comprimas lo que ya está comprimido. WebP, AVIF, WOFF2, PNG, JPEG, MP4 ya llevan compresión propia. Volver a comprimirlos gasta CPU y a veces aumenta el tamaño.
Comprueba que la compresión está activa. Es el fallo de despliegue más común y más invisible: en el panel Network, si las columnas de tamaño transferido y de recursos coinciden, no hay compresión. En la línea base de Nómada Tareas, los 214 kB de JavaScript aparecían idénticos en las dos columnas. Esa sola observación valía 150 kB.
- Caché HTTP, hashing y el service worker de 07-05
La visita más rápida es la que no descarga nada. La caché HTTP lo consigue, y el hashing del apartado 8 es lo que la hace segura.
Las dos cabeceras que gobiernan el asunto:
| Cabecera | Qué hace |
|---|---|
Cache-Control: max-age=N |
El navegador puede usar la copia local N segundos sin preguntar |
Cache-Control: no-cache |
Puede guardarla, pero debe revalidar antes de usarla |
Cache-Control: immutable |
Promete que el contenido nunca cambiará: ni siquiera revalidar al recargar |
ETag: "abc123" |
Huella del contenido; el navegador la reenvía en If-None-Match |
Last-Modified |
Fecha; el navegador la reenvía en If-Modified-Since |
Con ETag, cuando expira el max-age el navegador pregunta y el servidor puede responder 304 Not Modified sin cuerpo: se ahorra el peso, pero no el viaje de ida y vuelta. Con immutable, no hay ni viaje.
Y aquí está la razón profunda del hashing. index-8f3a1c.js contiene un resumen del contenido en el nombre. Si el contenido cambia, el nombre cambia. Eso permite una estrategia sin compromisos:
# Activos con hash: cachear un año, sin revalidar jamás
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# El HTML: NUNCA cachear. Es quien apunta a los nombres nuevos
location = /index.html {
add_header Cache-Control "no-cache";
}
# El service worker: nunca cachear (aviso literal de 07-05)
location = /sw.js {
add_header Cache-Control "no-cache";
}flowchart TD
A["Despliegas una versión nueva"] --> B["index.html cambia<br/>(no-cache: se pide siempre)"]
B --> C{"¿Qué activos<br/>referencia?"}
C -->|"index-8f3a1c.js<br/>(sin cambios)"| D["Ya está en caché<br/><b>0 bytes</b>"]
C -->|"index-b7d20f.js<br/>(nombre nuevo)"| E["Se descarga<br/>solo lo que cambió"]
D --> F["Carga casi instantánea"]
E --> F
Eso es lo que hace valioso el manualChunks del apartado 8: si solo tocas la vista, modelo-2b7e40.js conserva su hash y los usuarios no lo vuelven a descargar.
20.1 El service worker de 07-05, ahora que hay compilación
Aquí hay un conflicto real que hay que resolver, y es el tipo de cosa que rompe despliegues. El sw.js de 07-05 precacheaba una lista escrita a mano:
// ✗ sw.js — esta lista ya no existe tras compilar
const RECURSOS_SHELL = [
'/', '/index.html', '/css/estilos.css',
'/js/app.js', '/js/modelo/tarea.js', '/js/modelo/tablero.js',
// …25 rutas más que ya no se sirven…
];Tras npm run build ninguna de esas rutas existe: ahora son /assets/index-8f3a1c.js y compañía. El cache.addAll fallaría entero, porque addAll es atómico: si una petición falla, no se guarda ninguna. El service worker no se instalaría y la aplicación perdería el modo sin conexión sin que nadie se dé cuenta hasta que un usuario coja el metro.
La solución es generar la lista en la compilación. Con un pequeño complemento propio, que además ilustra cómo se enganchan las herramientas:
// scripts/plugin-precache.mjs
import { writeFileSync } from 'node:fs';
/** Escribe dist/precache.json con los activos generados y sus hashes. */
export function generarPrecache() {
return {
name: 'generar-precache',
generateBundle(opciones, paquete) {
const rutas = ['/', '/index.html', '/offline.html', '/manifest.json'];
for (const [nombre, fichero] of Object.entries(paquete)) {
// Solo lo que hace falta para arrancar: nada de trozos perezosos ni mapas
if (nombre.endsWith('.map')) continue;
if (fichero.isDynamicEntry) continue;
rutas.push(`/${nombre}`);
}
this.emitFile({
type: 'asset',
fileName: 'precache.json',
source: JSON.stringify({ version: Date.now(), rutas }, null, 2)
});
}
};
}// sw.js — lee la lista generada en lugar de tenerla escrita
self.addEventListener('install', (evento) => {
evento.waitUntil((async () => {
const respuesta = await fetch('/precache.json', { cache: 'no-store' });
const { version, rutas } = await respuesta.json();
const cache = await caches.open(`nomada-shell-${version}`);
await cache.addAll(rutas);
})());
});
self.addEventListener('activate', (evento) => {
evento.waitUntil((async () => {
const respuesta = await fetch('/precache.json', { cache: 'no-store' });
const { version } = await respuesta.json();
// Borrar las cachés de versiones anteriores (07-05)
const nombres = await caches.keys();
await Promise.all(nombres
.filter((n) => n.startsWith('nomada-') && !n.endsWith(String(version)))
.map((n) => caches.delete(n)));
await self.clients.claim();
})());
});Tres avisos de despliegue que valen su peso en oro:
sw.jsconCache-Control: no-cache, como ya dijo 07-05. Si un CDN sirve unsw.jsviejo durante horas, tus usuarios se quedan congelados y no hay nada que puedas hacer desde el cliente.- No precachees los trozos perezosos. El
informes-*.jsque solo pide el 4 % de las visitas no debe descargarse en la instalación del service worker: sería exactamente el problema que acabamos de resolver, movido de sitio. Se cachea al pedirlo, constaleWhileRevalidate(07-05). - Conserva los ficheros del despliegue anterior unos días. Es la mitigación del apartado 13 para los
import()que fallan en pestañas abiertas durante un despliegue.
- El presupuesto de rendimiento en integración continua
En 09-01 quedó dicho que los presupuestos de cantidad son mucho más estables que los de tiempo, porque no dependen del ruido del ejecutor, y que se verían aquí. Vamos a montarlos.
Nivel 1: el presupuesto de bytes, comprobado con un script propio sobre la salida de la compilación.
// scripts/presupuesto.mjs
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { brotliCompressSync } from 'node:zlib';
// El presupuesto es la fila 10 de la línea base de 09-01
const PRESUPUESTO = {
jsInicialKB: 80, // ≤ 80 kB de JS sin comprimir en la ruta crítica
peticionesJs: 5, // ≤ 5 peticiones
cssKB: 25,
brotliTotalKB: 30
};
const html = readFileSync('dist/index.html', 'utf8');
// Los ficheros que el HTML pide directamente = la ruta crítica
const criticos = [...html.matchAll(/(?:src|href)="\/(assets\/[^"]+)"/g)].map((m) => m[1]);
const js = criticos.filter((f) => f.endsWith('.js'));
const css = criticos.filter((f) => f.endsWith('.css'));
const bytes = (f) => statSync(join('dist', f)).size;
const brotli = (f) => brotliCompressSync(readFileSync(join('dist', f))).length;
const jsKB = js.reduce((s, f) => s + bytes(f), 0) / 1024;
const cssKB = css.reduce((s, f) => s + bytes(f), 0) / 1024;
const brotliKB = criticos.reduce((s, f) => s + brotli(f), 0) / 1024;
console.table(criticos.map((f) => ({
Fichero: f,
kB: (bytes(f) / 1024).toFixed(2),
'kB (br)': (brotli(f) / 1024).toFixed(2)
})));
const fallos = [];
if (jsKB > PRESUPUESTO.jsInicialKB) fallos.push(`JS inicial ${jsKB.toFixed(1)} kB > ${PRESUPUESTO.jsInicialKB}`);
if (js.length > PRESUPUESTO.peticionesJs) fallos.push(`${js.length} peticiones JS > ${PRESUPUESTO.peticionesJs}`);
if (cssKB > PRESUPUESTO.cssKB) fallos.push(`CSS ${cssKB.toFixed(1)} kB > ${PRESUPUESTO.cssKB}`);
if (brotliKB > PRESUPUESTO.brotliTotalKB) fallos.push(`Brotli total ${brotliKB.toFixed(1)} kB > ${PRESUPUESTO.brotliTotalKB}`);
if (fallos.length > 0) {
console.error('\n✗ Presupuesto de rendimiento incumplido:');
for (const f of fallos) console.error(` · ${f}`);
process.exit(1); // ← rompe la compilación, como una prueba de Jest
}
console.log(`\n✓ Presupuesto cumplido: ${jsKB.toFixed(1)} kB de JS en ${js.length} peticiones`);Salida real sobre la compilación del apartado 9:
┌─────────┬──────────────────────────────┬───────┬─────────┐ │ (index) │ Fichero │ kB │ kB (br) │ ├─────────┼──────────────────────────────┼───────┼─────────┤ │ 0 │ 'assets/estilos-4c9e21.css' │ 17.90 │ 3.22 │ │ 1 │ 'assets/index-8f3a1c.js' │ 44.08 │ 14.93 │ │ 2 │ 'assets/modelo-2b7e40.js' │ 11.64 │ 3.44 │ │ 3 │ 'assets/vitales-9c1d5a.js' │ 2.61 │ 1.02 │ └─────────┴──────────────────────────────┴───────┴─────────┘ ✓ Presupuesto cumplido: 58.3 kB de JS en 3 peticiones
Nivel 2: Lighthouse CI, que ya configuraste en 09-01, con las aserciones ahora ajustadas a los objetivos de la tabla:
{
"ci": {
"collect": {
"url": ["http://localhost:4173/"],
"numberOfRuns": 3,
"startServerCommand": "npm run preview"
},
"assert": {
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }],
"first-contentful-paint": ["warn", { "maxNumericValue": 1800 }],
"unused-javascript": ["warn", { "maxNumericValue": 20000 }],
"uses-text-compression": "error",
"unsized-images": "error",
"font-display": "error",
"uses-responsive-images": "off"
}
},
"upload": { "target": "temporary-public-storage" }
}
}Fíjate en las cuatro aserciones nuevas y en por qué son de regla y no de tiempo: uses-text-compression detecta que alguien ha desactivado Brotli en el servidor, unsized-images detecta un <img> sin dimensiones —el CLS del apartado 18— y font-display detecta un @font-face sin swap. Son comprobaciones binarias y estables, exactamente las que conviene poner como error.
Y el flujo de trabajo completo, siguiendo el orden barato-primero de 08-06:
# .github/workflows/ci.yml (fragmento)
rendimiento:
needs: [pruebas]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run build
- run: npm run presupuesto # barato, estable, rompe la compilación
- run: npx lhci autorun # caro, algo ruidoso, umbrales con margen
- uses: actions/upload-artifact@v4
with:
name: informe-paquete
path: dist/informe-paquete.htmlEl último paso guarda el mapa de árbol de rollup-plugin-visualizer como artefacto de la ejecución. Cuando dentro de tres meses el presupuesto falle porque alguien añadió una librería de gráficos, ese informe dirá en treinta segundos cuál es.
- La tabla completa: las diez filas, antes y después
Este es el cierre del contrato que 09-01 estableció. Mismo procedimiento —mediana de 15 repeticiones tras 5 de calentamiento, CPU 4×, Slow 4G, incógnito, mismo portátil de referencia, mismo tablero de 600 tareas con semilla fija—.
| # | Medida | Antes | Objetivo | Después | Dónde se resolvió |
|---|---|---|---|---|---|
| 1 | LCP (móvil simulado) | 4,1 s | ≤ 2,5 s | 1,9 s ✅ | 09-05 (8–11, 14, 17) |
| 2 | INP al escribir en el buscador | 480 ms | ≤ 200 ms | 42 ms ✅ | 09-02 (caché + debounce), 09-04 (render) |
| 3 | CLS | 0,21 | ≤ 0,1 | 0,02 ✅ | 09-05 (15, 17, 18) |
| 4 | Tarea larga máxima en el arranque | 1.180 ms | ≤ 200 ms | 41 ms ✅ | 09-02 (porLotes) |
| 5 | render() completo, 600 tareas |
310 ms | ≤ 50 ms | 31 ms ✅ | 09-04 (read-then-write, huella, virtualización) |
| 6 | Recálculos de resumen() por filtrado |
96 ms | ≤ 5 ms | 1,5 ms ✅ | 09-02 (caché por versión) |
| 7 | Informe de planificación | 940 ms | 0 ms en el hilo principal | 52 ms ⚠️ | 09-02 (Web Worker) |
| 8 | Memoria retenida tras 200 filtrados | +37,7 MB | ≈ 0 | +0,4 MB ✅ | 09-03 (AbortController, purga del índice) |
| 9 | Nodos DOM del documento | 7.812 | ≤ 1.500 | 1.194 ✅ | 09-04 (ListaVirtual) |
| 10 | JS descargado antes de la 1.ª tarjeta | 214 kB / 28 pet. | ≤ 80 kB / ≤ 5 | 58,3 kB / 3 pet. ✅ | 09-05 (8–12) |
Y el desglose de la fila 1, porque es la que resume el trabajo de esta lección:
| Paso | JS inicial | Peticiones | LCP | CLS |
|---|---|---|---|---|
| Línea base | 214 kB | 28 | 4,10 s | 0,21 |
| Empaquetado con Vite | 209 kB | 2 | 3,20 s | 0,21 |
| Minificación | 118 kB | 2 | 2,80 s | 0,21 |
| Tree shaking | 104 kB | 2 | 2,70 s | 0,21 |
| División de código | 58,3 kB | 3 | 2,40 s | 0,21 |
| Compresión Brotli en el servidor | 58,3 kB (19,4 kB transferidos) | 3 | 2,15 s | 0,21 |
preconnect, preload de la fuente, modulepreload |
58,3 kB | 3 | 2,00 s | 0,21 |
| Subconjunto de la fuente (94 → 21,4 kB) | 58,3 kB | 3 | 1,90 s | 0,21 |
| Imágenes con dimensiones, esqueleto, respaldo ajustado | 58,3 kB | 3 | 1,90 s | 0,02 |
Y la barra de resumen del panel Network, comparada con la de 09-01:
Antes: 31 requests · 238 kB transferred · 512 kB resources · DCL 1,9 s · Load 4,3 s Después: 9 requests · 57 kB transferred · 138 kB resources · DCL 0,9 s · Load 2,0 s
- Lo que no se ha resuelto y lo que ha costado
Nueve palomitas verdes y un aviso ámbar. Merece la pena mirar el aviso, y sobre todo la factura.
La fila 7 no llegó a cero. El objetivo era «0 ms en el hilo principal» y el resultado es 52 ms: 3,3 ms de serializar la ida, unos 45 de la vuelta con el informe completo y unos pocos de recibirlo. Sacar el cálculo del hilo principal no elimina el coste de cruzar la frontera, como 09-02 explicó con la clonación estructurada. Se podría bajar más usando un ArrayBuffer transferible para los datos numéricos, pero eso obligaría a serializar a mano una estructura que hoy es un objeto legible. Cincuenta y dos milisegundos son un fotograma perdido, no una interfaz congelada. Es una decisión consciente, no un descuido, y así es como hay que documentarla.
El 0,02 de CLS que queda es la barra de resumen al pasar de tres cifras, y ya está explicado en el apartado 18: perseguir el cero exigiría fijar la altura de algo genuinamente variable.
Nada de esto está medido en campo. Todos estos números son de laboratorio, en un portátil concreto con estrangulamiento simulado. El web-vitals que instalaste en 09-01 sigue siendo la única forma de saber qué le pasa de verdad a Marta en su móvil. La distinción laboratorio/campo de 09-01 no desaparece porque la tabla esté verde.
Y ahora la factura, que es la parte que casi nunca se escribe:
| Lo que se ha ganado | Lo que ha costado |
|---|---|
| LCP 2,2 s más rápido | Un paso de compilación: la aplicación ya no se abre con doble clic en index.html |
| 156 kB menos en la ruta crítica | Dos dependencias de desarrollo y un vite.config.js que hay que entender |
| Render 10× más rápido | ~90 líneas de ListaVirtual, con aria-setsize, Ctrl+F roto y un modo de fallo nuevo |
| Sin fugas de memoria | Un destruir() en cada clase y un AbortController que hay que acordarse de usar |
| Informe sin congelar la interfaz | Un worker, un protocolo de mensajes con id y serialización a datos planos |
| Caché eterna segura | Nombres con hash, y un sw.js que ya no puede tener la lista escrita a mano |
| Presupuesto vigilado | Dos trabajos más de CI que alguien tendrá que mantener |
Todo eso es complejidad real, y no toda estaba justificada de antemano. Si Taller Nómada tuviera seis tareas en lugar de seiscientas, casi nada de este módulo habría sido necesario: el render costaba 1,4 ms, no había fugas perceptibles y 214 kB en una red decente son medio segundo. La disciplina de 09-01 no consistía en optimizarlo todo, sino en medir primero para saber qué merecía la pena, y esa disciplina incluye la decisión contraria: no hacer nada cuando la medición dice que no hace falta.
Si tuvieras que quedarte solo con tres cosas de las quince que has hecho, las tres con mejor relación entre beneficio y complejidad serían: arreglar el layout thrashing (310 → 179 ms, media hora de trabajo, cero complejidad añadida), empaquetar y minificar (4,1 → 2,8 s de LCP, un fichero de configuración) y el subconjunto de la fuente (72,6 kB menos, un comando de terminal). Las tres son cambios de infraestructura o de orden, no de arquitectura. La virtualización y el worker, que son las que más impresionan, son también las que más deuda dejan.
Errores Comunes y Consejos
- Optimizar la ejecución cuando el problema es la carga. Durante los 4,1 s de la línea base, tu código no se había ejecutado todavía. Mide dónde está el tiempo antes de decidir qué tocar.
- Poner un
<script>clásico sindeferen el<head>. Detiene el análisis del HTML. Contype="module"el problema no existe. - Usar
asynccreyendo que es «deferpero mejor».asyncno garantiza el orden y ejecuta en cuanto llega, posiblemente en el peor momento. - Leer Coverage nada más cargar y llamar «código muerto» a lo que solo es «código todavía no ejecutado». Son dos preguntas distintas y exigen dos formas de parar la grabación.
- Creer que empaquetar es «meterlo todo en un fichero». Hoy el objetivo es pocos ficheros bien elegidos, separados por frecuencia de cambio y por momento de uso.
- Transpilar a ES5 «por si acaso». Engorda el paquete un 20–30 % y añade polyfills que ningún navegador con módulos ES necesita.
- Desactivar los mapas de origen en producción. No se descargan salvo que se abra DevTools, y sin ellos un error real es ilegible.
- Suponer que el tree shaking funciona. Falla con
import *usados de forma opaca, con módulos que tienen efectos secundarios y con dependencias que solo publican CommonJS. Compruébalo con el visualizador. - Dividir de más. Cada
import()es un viaje de ida y vuelta. Dividir un módulo de 3 kB cambia 3 kB por 280 ms enSlow 4G. - Usar
import(variable)a secas. El empaquetador no sabe qué incluir. Deja la parte fija visible:import(\./textos/${codigo}.js`)`. - No manejar el fallo de un
import()dinámico. Un despliegue nuevo puede borrar el trozo que una pestaña abierta va a pedir. Detecta el error y ofrece recargar. - Cachear la promesa de un
import()fallido. Todos los intentos posteriores fallarán para siempre. Bórrala al agotar los reintentos. - Precargar demasiadas cosas. Si todo es prioritario, nada lo es.
preloadfunciona porque desplaza otros recursos en la cola. preloadde una fuente sincrossorigin. Provoca dos descargas del recurso más caro de la página. Es el error clásico.loading="lazy"en la imagen LCP. Retrasa deliberadamente la métrica principal. Lighthouse lo detecta y te riñe.- Imágenes sin
widthyheight. Es la causa número uno de CLS, y los dos números no interfieren con el diseño adaptable. - Servir una fuente completa de 94 kB para escribir en español. Haz un subconjunto: 21,4 kB con los mismos glifos que usas.
font-display: blockoauto. Texto invisible hasta tres segundos.swapcomo mínimo,optionalsi el CLS manda.- Creer que un esqueleto mejora el LCP. Mejora el CLS y la percepción; el LCP mide contenido, y un esqueleto no lo es.
- No comprobar que la compresión está activa. Si en Network coinciden «transferido» y «recursos», no hay Gzip ni Brotli. Vale 150 kB comprobarlo.
- Volver a comprimir WebP, WOFF2 o MP4. Ya están comprimidos: gastas CPU y a veces engordan.
- Cachear el HTML o el
sw.js. El HTML apunta a los nombres con hash; si se cachea, los usuarios se quedan en la versión antigua para siempre. - Dejar la lista de precaché del service worker escrita a mano tras añadir compilación.
addAlles atómico: una ruta inexistente y no se instala nada. - Precachear los trozos perezosos. Volverías a descargar en el arranque justo lo que acababas de sacar de la ruta crítica.
- Consejo: mide siempre con Disable cache y
Slow 4G. Sin eso estás midiendo tu segunda visita con fibra. - Consejo: separa los trozos por frecuencia de cambio, no solo por tamaño. Es lo que hace que la caché funcione con grano fino.
- Consejo: guarda el informe del visualizador como artefacto de CI. Cuando el presupuesto falle en tres meses, tendrás la respuesta en treinta segundos.
- Consejo: pon como
errorlas aserciones binarias y comowarnlas de tiempo. «Falta compresión» es determinista; «LCP 2,6 s» puede ser el ejecutor teniendo un mal día. - Consejo: escribe en el código el número que justificó cada optimización, con su fecha. Dentro de dos años, quien lea
ListaVirtualmerece saber que se puso para bajar de 7.812 nodos a 1.194.
Ejercicios
Ejercicio 1 — Diagnóstico desde el panel Network. Una compañera despliega Nómada Tareas en su propio servidor y te enseña esta barra de resumen y este extracto, medidos con Slow 4G e incógnito:
| Recurso | Size (transferido / recurso) | Priority | Tiempo |
|---|---|---|---|
index.html |
9,1 kB / 9,1 kB | Highest | 620 ms |
assets/index-8f3a1c.js |
44,1 kB / 44,1 kB | High | 1.240 ms |
assets/estilos-4c9e21.css |
17,9 kB / 17,9 kB | Highest | 980 ms |
assets/informes-5a3f18.js |
18,2 kB / 18,2 kB | High | 1.310 ms |
assets/editor-7e1b93.js |
24,4 kB / 24,4 kB | High | 1.380 ms |
fonts/inter-variable.woff2 |
94 kB / 94 kB | Highest | 2.100 ms |
img/portada.png |
78 kB / 78 kB | Low | 2.900 ms |
Identifica cuatro problemas distintos, di en qué te basas exactamente para afirmar cada uno, y propón la corrección concreta de cada uno. Después estima cuál de los cuatro tendrá mayor impacto en el LCP y por qué.
Ejercicio 2 — Decidir qué dividir. Taller Nómada quiere cuatro funcionalidades nuevas. Para cada una, decide entre «importación estática», «import() al activarse» e «import() con precarga en requestIdleCallback», justificando con las tres condiciones del apartado 12 y con el coste de un viaje de ida y vuelta en Slow 4G (~280 ms).
- Un validador de formularios de 2,1 kB que se usa al dar de alta cualquier tarea.
- Un exportador a PDF de 186 kB que usa el 3 % de las visitas, siempre al final de la sesión.
- Un selector de emojis de 34 kB para los comentarios; lo usa el 40 % de las visitas, típicamente en los primeros treinta segundos.
- Un módulo de accesibilidad de 6 kB que instala atajos de teclado y se activa nada más cargar.
Ejercicio 3 — Cerrar un CLS de 0,34. El nuevo panel de estadísticas de Nómada Tareas tiene un CLS de 0,34, repartido así según el panel Performance: 0,18 al insertarse un banner de aviso de cookies en la parte superior, 0,09 al llegar un gráfico cargado con import() dinámico, 0,05 al cambiar la fuente, y 0,02 por un <img> de logotipo. Para cada uno: (a) explica por qué se produce el salto, (b) escribe la corrección concreta con su código, y (c) di si el CLS resultante cumpliría el objetivo de ≤ 0,1. Después responde: ¿por qué un salto provocado por el usuario (pulsar «Ver detalles» y que se despliegue un panel) no cuenta para el CLS?
Soluciones
Solución 1
Problema 1: no hay compresión. Se ve directamente en la columna Size: transferido y recurso coinciden en todos los ficheros de texto (44,1 / 44,1, 17,9 / 17,9, 9,1 / 9,1), y el resumen dice 291 kB transferred · 294 kB resources. Con Brotli, esos 71,1 kB de JS y CSS viajarían como unos 21,6 kB.
gzip on;
brotli on;
brotli_types text/html text/css application/javascript application/json image/svg+xml;
brotli_static on; # servir los .br generados en la compilaciónProblema 2: se descargan los trozos perezosos. informes-5a3f18.js y editor-7e1b93.js aparecen en la carga inicial con prioridad High. Eso significa que el HTML los referencia, casi con certeza con <link rel="modulepreload"> puestos a mano «para que vayan más rápido». Están anulando exactamente el beneficio de la división de código: 42,6 kB de la ruta crítica que el 89 % de las visitas no usa. La corrección es quitar esos dos modulepreload del HTML y, si se quiere anticipar la descarga del editor, hacerlo desde requestIdleCallback como en el apartado 14.
Problema 3: la fuente no tiene subconjunto y llega tardísimo. 94 kB y 2.100 ms, con prioridad Highest, es la fuente completa sin subconjunto (apartado 17.2). Además, si tarda 2,1 s es que se ha descubierto tarde, al analizar el CSS. Doble corrección: pyftsubset para bajar a ~21 kB y <link rel="preload" as="font" crossorigin> en el <head>.
Problema 4: la imagen de portada es el elemento LCP y tiene prioridad Low. Es la más pesada (78 kB), es un PNG y es la última en llegar, a 2.900 ms. Low indica que lleva loading="lazy" o que el navegador la ha despriorizado por no saber que es importante.
<img src="/assets/portada-800.webp"
srcset="/assets/portada-400.webp 400w, /assets/portada-800.webp 800w"
sizes="(max-width: 640px) 100vw, 800px"
width="800" height="450" alt="Vista del taller"
fetchpriority="high" decoding="async">Cuál pesa más en el LCP. El problema 4, sin discusión. El elemento LCP es la portada, y llega a 2.900 ms; ninguna otra corrección puede bajar el LCP por debajo de ese momento. Convertirla a WebP (78 → 22 kB), servirla dimensionada al viewport y subirle la prioridad la adelanta a unos 1.100 ms. Los problemas 1 y 2 son los siguientes en importancia, porque liberan ancho de banda de la ruta crítica que la portada estaba teniendo que compartir con 42,6 kB de JavaScript inútil. Es una buena ilustración de que las optimizaciones de red interactúan: quitar peso a un recurso acelera a los demás.
Solución 2
| # | Funcionalidad | Decisión | Justificación |
|---|---|---|---|
| 1 | Validador, 2,1 kB, siempre | Importación estática | Falla la condición (1) —no pesa— y falla la (2) —se usa enseguida—. Dividirlo cambiaría 2,1 kB (≈0,6 kB con Brotli) por 280 ms de espera en mitad de un formulario, que es el peor momento posible |
| 2 | Exportador a PDF, 186 kB, 3 % | import() al activarse |
Cumple las tres condiciones de forma ejemplar: pesa mucho, no se usa en la primera pantalla y se activa con un botón. Además, quien exporta ya espera un momento por la generación, así que los 280 ms se camuflan. Y como se usa al final de la sesión, precargarlo antes sería gastar 186 kB en el 97 % de las visitas que nunca lo usarán |
| 3 | Selector de emojis, 34 kB, 40 % | import() con precarga en requestIdleCallback |
Cumple las tres condiciones para dividirlo, pero la probabilidad de uso es alta y el uso es temprano: si esperas al clic, cuatro de cada diez usuarios verán una pausa. Precargarlo cuando el navegador está ocioso da lo mejor de los dos mundos: fuera de la ruta crítica, pero ya en caché cuando haga falta |
| 4 | Accesibilidad, 6 kB, siempre al cargar | Importación estática | Falla la condición (3): no se activa por una acción, se necesita desde el primer momento. Dividirlo dejaría la aplicación sin atajos de teclado durante los primeros 280 ms, y sería una regresión de accesibilidad a cambio de 6 kB |
La lectura transversal: la decisión no depende solo del tamaño. El caso 2 y el caso 3 pesan de forma muy distinta y ambos se dividen; el caso 1 y el caso 4 también pesan de forma distinta y ninguno se divide. Lo que manda es cuándo se necesita y con qué probabilidad.
Solución 3
(a) y (b), salto por salto:
El banner de cookies (0,18). Se inserta en el flujo normal, en la parte superior, después de que la página ya esté pintada, y empuja hacia abajo todo lo demás. Es el caso más dañino posible: mucho desplazamiento, y afecta a toda la pantalla. Dos correcciones, y la segunda es la buena:
/* ✓ Mejor: sacarlo del flujo. Si está fijo, no puede empujar a nadie */
.banner-cookies {
position: fixed;
bottom: 0; left: 0; right: 0;
z-index: 100;
}/* ✓ Alternativa si tiene que ir arriba en el flujo: reservar su altura desde el principio */
.hueco-banner { min-height: 64px; } /* el hueco existe antes de que llegue el banner */El gráfico cargado con import() (0,09). Su contenedor mide 0 px hasta que el componente se monta, y al montarse crece de golpe. Es exactamente el aviso del apartado 16:
#panel-grafico {
min-height: 280px; /* o aspect-ratio: 16 / 9 */
contain: layout paint; /* además, aísla el recálculo (09-04) */
}El cambio de fuente (0,05). Al llegar la fuente web, sus métricas difieren de las del respaldo y el texto se recoloca. La corrección es la del apartado 17.4: un @font-face de respaldo con size-adjust, ascent-override, descent-override y line-gap-override, más font-display: swap y preload con crossorigin.
@font-face {
font-family: 'Inter respaldo';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body { font-family: 'Inter', 'Inter respaldo', system-ui, sans-serif; }El logotipo (0,02). Falta la reserva de espacio:
<img src="/assets/logo-taller-a91c4e.webp" alt="Taller Nómada"
width="180" height="48" fetchpriority="high" decoding="async">(c) ¿Cumple? Sí, con margen. El banner fijo elimina 0,18, el min-height del gráfico elimina 0,09, el respaldo ajustado elimina 0,05 y las dimensiones del logotipo eliminan 0,02: CLS resultante ≈ 0,00–0,02, muy por debajo del objetivo de 0,1. Conviene medirlo, no darlo por hecho: el panel Rendering de DevTools con Layout Shift Regions señala visualmente cualquier salto que quede.
Por qué un salto provocado por el usuario no cuenta. La especificación de CLS ignora los desplazamientos que ocurren dentro de una ventana de 500 ms tras una interacción del usuario (una pulsación de tecla, un clic, un toque). La razón es que el CLS pretende medir sorpresa, no movimiento: si Marta pulsa «Ver detalles» y el panel se despliega empujando el contenido, eso es exactamente lo que ha pedido y no le desconcierta. Lo que arruina la experiencia es que el contenido salte mientras lee, haciéndole pulsar el botón equivocado.
Dos consecuencias prácticas de ese detalle. La primera: no intentes «esconder» tus saltos detrás de una interacción falsa, porque el objetivo es la experiencia real, no la métrica. La segunda, más útil: si un despliegue tarda más de 500 ms en producirse tras el clic —porque hay un import() de por medio—, el salto sí contará, y ahí sí hay que reservar el hueco. Es otro argumento para el min-height del punto anterior.
Conclusión
El contrato de 09-01 está cerrado. Diez números medidos, diez números vueltos a medir con el mismo procedimiento, y una tabla que ya no es una lista de quejas sino un registro de decisiones: LCP de 4,1 a 1,9 s, INP de 480 a 42 ms, CLS de 0,21 a 0,02, tarea larga de 1.180 a 41 ms, render de 310 a 31 ms, recálculos de 96 a 1,5 ms, informe de 940 a 52 ms de bloqueo, memoria de +37,7 MB a +0,4 MB, nodos de 7.812 a 1.194, y JavaScript inicial de 214 kB en 28 peticiones a 58,3 kB en 3.
Entiendes el rendimiento que se decide antes de ejecutar una línea. Conoces la ruta crítica de renderizado y sabes por qué el CSS bloquea el renderizado —para no mostrar una página sin estilos que saltaría después— y por qué un <script> clásico bloquea el análisis, con la tabla completa de defer, async y type="module" que cierra lo que 01-03 y 06-01 dejaron apuntado. Sabes medir el peso real con el panel Network —Disable cache, Slow 4G, la columna Size con sus dos valores que delatan la falta de compresión, Priority e Initiator— y con la pestaña Coverage, que te dijo el dato incómodo del que sale todo: el 62 % del JavaScript descargado no se ejecutaba antes de la primera tarjeta.
Sabes qué hace un empaquetador y, más importante, por qué existe: no para «meterlo todo en un fichero», sino para resolver los identificadores desnudos que el navegador no entiende, para eliminar la cascada de descubrimiento que costaba 1,1 s en cuatro niveles, y para poner nombres con hash que hagan segura la caché eterna. Tienes un vite.config.js real y comentado, con target: 'es2022' en lugar de transpilar de más, mapas de origen activados, y manualChunks separados por frecuencia de cambio y no por tamaño. Sabes qué hace la minificación (214 → 118 kB) y qué hace el tree shaking (118 → 104 kB), y por qué este último solo es posible gracias a que los import/export de los módulos ES son estáticos —la restricción de 05-04 que ahora tiene sentido— con sus cuatro condiciones para funcionar de verdad: importaciones nominales, sideEffects declarados, dependencias en ESM y código realmente inalcanzable.
Sabes dividir con import() dinámico aplicando las tres condiciones —que pese, que no se use en la primera pantalla y que lo active una acción concreta—, con el informe y su worker, el editor enriquecido y las traducciones fuera de la ruta crítica: 46 kB menos para el 100 % de las visitas. Y sabes hacerlo bien: la promesa cacheada que se borra al fallar, el literal parcial que permite al empaquetador descubrir las rutas variables, aria-busy en el indicador, una degradación digna cuando el trozo no llega y la detección del despliegue nuevo que invalidó los hashes. Conoces las cinco sugerencias de recurso y cuándo usar cada una, con sus tres reglas: precarga poco, nunca lo que no vas a usar en esta pantalla, y crossorigin obligatorio en las fuentes.
Sabes hacer perezosas las imágenes —loading="lazy" salvo en la LCP, decoding="async", fetchpriority="high" solo para el elemento LCP, width y height siempre, WebP antes que discutir sobre carga perezosa— y los componentes, con IntersectionObserver y rootMargin generoso sobre un hueco ya reservado. Y sabes lo que casi nadie mira: que una fuente puede pesar más que todo tu JavaScript, y que font-display: swap, un subconjunto de 94 a 21,4 kB, la precarga con crossorigin y una fuente de respaldo con métricas ajustadas valen más que muchas horas optimizando código. Con ellas, y con las dimensiones de las imágenes y un esqueleto que reserva el sitio de las tarjetas, cerraste el CLS de 0,21 a 0,02, sabiendo además que un esqueleto no cuenta para el LCP y que un salto provocado por el usuario no cuenta para el CLS.
Cierras el módulo con la infraestructura: Gzip y Brotli generados en la compilación y no al vuelo, sin recomprimir lo ya comprimido y comprobando siempre en Network que están activos; la caché HTTP con immutable de un año para los activos con hash, no-cache para el HTML y para sw.js, y ETag para lo demás; la reconciliación con el service worker de 07-05, cuya lista de precaché escrita a mano dejaba de funcionar en cuanto hubo compilación y ahora se genera durante la construcción, sin incluir los trozos perezosos; y el presupuesto de rendimiento de 09-01 convertido por fin en algo que rompe la integración continua: un script de bytes y peticiones —estable, determinista y barato— más Lighthouse CI con sus aserciones de regla, y el mapa de árbol de rollup-plugin-visualizer guardado como artefacto para el día en que alguien añada una librería de 90 kB sin darse cuenta. Con la honestidad final por delante: la fila 7 se quedó en 52 ms y no en cero, el 0,02 de CLS es una decisión y no un descuido, todo esto es laboratorio y no campo, y cada mejora ha tenido su factura en complejidad —un paso de compilación, noventa líneas de virtualización, un Ctrl+F roto, un destruir() en cada clase— que con seis tareas en el tablero no habría estado justificada.
Y aquí es donde el módulo entrega algo que no está en ninguna tabla. Para que Nómada Tareas responda como responde has tenido que construir a mano, pieza a pieza, un conjunto muy concreto de mecanismos: un render declarativo que describe la pantalla a partir del estado (06-06), una reconciliación por clave estable que reutiliza los nodos en lugar de destruirlos, un estado centralizado con invalidación explícita y caché por versión (09-02), una limpieza sistemática con destruir() y AbortController para que nada quede colgando (09-03), una lista virtualizada que mantiene constante el número de nodos (09-04), y una división de código con carga perezosa, precarga y manejo del fallo (09-05). Esa lista no es casual: es, casi punto por punto, lo que un framework moderno te da resuelto de fábrica el primer día. React te pide una key en las listas —que es literalmente tu data-id—, Vue reconstruye solo lo que cambia, Angular trae división de código en el enrutador, y los tres gestionan por ti el ciclo de vida y la limpieza que tú has escrito a mano. La diferencia es que ahora sabes qué problema resuelven, cuánto cuesta resolverlo y qué se paga por no resolverlo tú, que es exactamente la posición desde la que se puede juzgar si compensan en lugar de adoptarlos por costumbre. Con esa perspectiva empieza el siguiente módulo: Por Qué Existen los Frameworks.
Curso de JavaScript: De Principiante a Avanzado
Módulo 1: Introducción a JavaScript
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
