Dividir por interacción: cargar cuando el usuario demuestra intención
El corte dentro de una ruta, las señales de intención que dan tiempo suficiente para cargar sin que se note, el patrón de fachada, y cómo evitar que el estado de carga empeore la experiencia.
Dentro de una sola ruta hay funcionalidad que la mayoría de los visitantes no llega a usar: el selector de fecha del formulario avanzado, el editor enriquecido de los comentarios, el mapa de la pestaña de ubicación, el visor de facturas. Todo eso se carga en el arranque porque está en el mismo módulo que lo demás, y el coste lo pagan todos para que lo use una minoría. El corte por interacción resuelve eso, y la parte difícil no es técnica: es elegir el momento en que se dispara la carga para que el usuario no espere.
- Identificar qué funcionalidad de una ruta se presta al corte por interacción.
- Elegir la señal de intención adecuada según el tiempo de carga del fragmento.
- Implementar el patrón de fachada para incrustaciones pesadas de terceros.
- Diseñar el estado de carga para que no cause desplazamiento ni bloqueo.
Qué se corta y cuánto se ahorra
El criterio es simple: todo lo que solo aparece después de un clic, un enfoque o un desplazamiento es candidato. Los casos que más pesan, con las cifras que se suelen ver:
| Funcionalidad | Peso comprimido típico | Fracción de usuarios que la usa |
|---|---|---|
| Editor de texto enriquecido | 90-180 KB | 5-15 % |
| Biblioteca de gráficas | 50-90 KB | 20-40 % |
| Mapa incrustado | 100-250 KB + peticiones | 10-25 % |
| Selector de fecha con calendario completo | 25-50 KB | 30-60 % |
| Reproductor de vídeo con biblioteca | 130-200 KB | 5-20 % |
| Ventana de chat de soporte | 150-400 KB | 1-3 % |
| Visor de documentos PDF | 300-800 KB | 5-10 % |
La última columna es la que decide. Un módulo de 400 KB que usa el 2 por ciento de los visitantes cuesta, repartido, 8 KB efectivos por visita si se carga a demanda, y 400 si no.
La estructura del corte es la misma que en las rutas, con el disparador en el manejador:
const boton = document.querySelector('#abrir-editor');
let editorPromesa = null;
boton.addEventListener('click', async () => {
boton.disabled = true;
boton.textContent = 'Abriendo…';
editorPromesa ??= import('./editor/EditorRico.js');
const { crearEditor } = await editorPromesa;
crearEditor(document.querySelector('#zona-editor'));
boton.hidden = true;
});
El ??= sobre la promesa es importante: evita que dos clics rápidos disparen dos cargas, y hace que el módulo se pida una sola vez aunque el usuario abra y cierre varias veces. El navegador tampoco descargaría el fichero dos veces, pero la promesa memorizada evita ejecutar el módulo dos veces y crear dos instancias.
La señal de intención: cargar antes del clic
Si esperas al clic, el usuario espera la descarga. Con un fragmento de 90 KB en una red móvil son 400 o 500 milisegundos de nada, más el procesamiento. Es aceptable pero se nota.
La mejora consiste en cargar antes, aprovechando que hay señales que preceden al clic con margen suficiente. Ordenadas de más anticipada a más tardía:
El desplazamiento hacia la zona. Un observador de intersección con margen te avisa cuando el componente está a una pantalla de distancia. Da segundos de margen. Es la señal correcta para gráficas, mapas y cualquier cosa que esté más abajo en la página.
El puntero sobre el elemento. El intervalo entre que el ratón entra en un botón y el clic ocurre está típicamente entre 150 y 400 milisegundos. Es suficiente para un fragmento pequeño en buena red, y con caché caliente es suficiente para cualquier cosa.
El puntero pulsado. El pointerdown ocurre entre 60 y 120 milisegundos antes del click. Poco margen, pero es la señal más fiable de todas y funciona igual en táctil, donde el mouseenter no existe.
El enfoque por teclado. Equivalente al puntero encima para quien navega con teclado. No lo olvides: implementar solo mouseenter deja fuera a los usuarios de teclado, que además suelen ser los más rápidos.
Un ayudante que combina las tres señales tardías y funciona en cualquier dispositivo:
function precargarConIntencion(elemento, cargar) {
let disparado = false;
const disparar = () => {
if (disparado) return;
disparado = true;
cargar(); // devuelve la promesa; la memorizacion la hace quien llama
};
elemento.addEventListener('pointerenter', disparar, { once: true, passive: true });
elemento.addEventListener('pointerdown', disparar, { once: true, passive: true });
elemento.addEventListener('focusin', disparar, { once: true });
return () => {
elemento.removeEventListener('pointerenter', disparar);
elemento.removeEventListener('pointerdown', disparar);
elemento.removeEventListener('focusin', disparar);
};
}
let editorPromesa = null;
const cargarEditor = () => (editorPromesa ??= import('./editor/EditorRico.js'));
precargarConIntencion(document.querySelector('#abrir-editor'), cargarEditor);
Y la variante por proximidad, para lo que está más abajo:
const io = new IntersectionObserver(
(entradas, obs) => {
for (const e of entradas) {
if (!e.isIntersecting) continue;
obs.unobserve(e.target);
import('./graficas/Panel.js').then(({ montar }) => montar(e.target));
}
},
{ rootMargin: '400px 0px' } // empieza cuando falta media pantalla
);
document.querySelectorAll('[data-grafica]').forEach((el) => io.observe(el));
El rootMargin de 400 píxeles es el parámetro que hay que ajustar: demasiado pequeño y el usuario ve el hueco, demasiado grande y cargas cosas que nadie va a ver. Un valor entre media y una pantalla es un buen punto de partida.
El patrón de fachada
Para incrustaciones de terceros —vídeo, mapas, chat, redes sociales— el corte por interacción tiene una forma específica y muy rentable: sustituir la incrustación por una imagen estática que la imita, y cargar la incrustación real solo al hacer clic.
El ahorro es de otro orden de magnitud que el de un módulo propio, porque una incrustación de terceros no trae solo JavaScript: trae su propio documento, sus propias conexiones a dominios nuevos, sus propias cookies y su propio trabajo de hilo principal. Un vídeo incrustado típico son entre 400 KB y 1,2 MB y varios cientos de milisegundos de bloqueo.
<button class="fachada-video" type="button"
data-id="dQw4w9WgXcQ"
aria-label="Reproducir el vídeo: cómo se encuaderna a mano">
<img src="/media/portada-video-800.avif"
srcset="/media/portada-video-600.avif 600w, /media/portada-video-1000.avif 1000w"
sizes="(min-width: 800px) 760px, 100vw"
width="1280" height="720" alt="" loading="lazy" decoding="async">
<span class="icono-play" aria-hidden="true"></span>
</button>
.fachada-video {
position: relative; display: block; inline-size: 100%;
aspect-ratio: 16 / 9; padding: 0; border: 0; cursor: pointer; background: #000;
}
.fachada-video img { inline-size: 100%; block-size: 100%; object-fit: cover; }
.icono-play {
position: absolute; inset-block-start: 50%; inset-inline-start: 50%;
translate: -50% -50%; inline-size: 68px; block-size: 48px;
background: color-mix(in oklch, #f38ba8 85%, transparent); border-radius: 12px;
}
document.addEventListener('click', (e) => {
const fachada = e.target.closest('.fachada-video');
if (!fachada) return;
const iframe = document.createElement('iframe');
iframe.src = `https://www.youtube-nocookie.com/embed/${fachada.dataset.id}?autoplay=1`;
iframe.allow = 'accelerometer; autoplay; encrypted-media; picture-in-picture';
iframe.allowFullscreen = true;
iframe.title = fachada.getAttribute('aria-label');
iframe.width = 1280; iframe.height = 720;
iframe.style.cssText = 'inline-size:100%;block-size:100%;border:0';
fachada.replaceWith(iframe);
});
Cuatro decisiones del fragmento que importan: la fachada es un <button> real y no un <div>, para que funcione con teclado; el aspect-ratio reserva el espacio y evita el desplazamiento al sustituir; el aria-label describe el vídeo concreto y se reutiliza como título del iframe; y el autoplay=1 en la URL hace que el vídeo arranque solo, porque el clic del usuario ya es la activación que la política de reproducción automática exige.
Para reducir aún más el coste, se puede añadir el preconectado a los dominios del tercero cuando el puntero entra en la fachada, de forma que el enlace TCP y TLS ya esté hecho cuando llegue el clic:
document.querySelectorAll('.fachada-video').forEach((f) => {
f.addEventListener('pointerenter', () => {
for (const origen of ['https://www.youtube-nocookie.com', 'https://i.ytimg.com']) {
const l = document.createElement('link');
l.rel = 'preconnect'; l.href = origen; l.crossOrigin = '';
document.head.append(l);
}
}, { once: true, passive: true });
});
La división por interacción es fácil de implementar y fácil de estropear, y el fallo tiene siempre la misma forma: el estado de carga cambia el tamaño de la caja.
La secuencia. El usuario pulsa. Aparece un indicador de carga pequeño donde antes había un botón. La caja se encoge. Trescientos milisegundos después llega el módulo, se monta el componente y la caja crece hasta su tamaño final. Has generado dos desplazamientos de contenido en una interacción que el usuario acaba de iniciar, que es el peor momento posible porque su atención está exactamente ahí. Y como es un desplazamiento posterior a una interacción, se contabiliza en el CLS solo si ocurre fuera de la ventana de gracia de 500 milisegundos tras la entrada, lo cual significa que a veces cuenta y a veces no, y tu métrica sube de forma aparentemente aleatoria.
Tres reglas que lo eliminan.
Uno: el contenedor tiene su tamaño final desde el principio. Antes de cargar nada, con min-block-size o aspect-ratio. Si no sabes el tamaño del componente que va a llegar, mídelo una vez y ponlo como constante. Un valor aproximado y estable es mucho mejor que uno exacto y tardío.
Dos: el disparador no desaparece hasta que el sustituto está en su sitio. No ocultes el botón al empezar la carga; deshabilítalo y cámbiale el texto. Ocuparlo es lo que mantiene la caja.
Tres, y es el que casi nadie hace: haz que el estado de carga tarde un mínimo o no aparezca. Si el módulo está en caché, la promesa se resuelve en 20 milisegundos y el indicador aparece y desaparece en un parpadeo que el usuario percibe como un error visual. La solución es no mostrar nada durante los primeros 150 o 200 milisegundos:
async function conIndicadorDemorado(promesa, mostrar, ocultar, retardo = 180) {
let visible = false;
const t = setTimeout(() => { visible = true; mostrar(); }, retardo);
try {
return await promesa;
} finally {
clearTimeout(t);
if (visible) ocultar();
}
}Y un cuarto punto que es de accesibilidad y de percepción a la vez: cuando el componente cargado sustituye al disparador, hay que mover el foco. El usuario que pulsó con teclado tenía el foco en el botón; si el botón desaparece, el foco vuelve al body y la navegación siguiente empieza desde arriba. Un elemento.focus() sobre el primer control del componente nuevo cierra el círculo, y es la diferencia entre una carga diferida invisible y una que rompe el flujo de la mitad de tus usuarios avanzados.
Qué no cortar
Tres casos donde el corte por interacción hace más daño que bien.
Lo que se usa casi siempre. Si el 85 por ciento de los visitantes abre el selector de fecha, cortarlo añade una espera a casi todo el mundo para ahorrar a una minoría. Por debajo del 50 por ciento de uso, el corte gana; por encima del 70, pierde.
Lo que pesa poco. Un componente de 6 KB no justifica una petición adicional, un estado de carga y el riesgo de fallo. El umbral razonable está en 15 o 20 KB comprimidos.
Lo que se necesita en la ruta crítica de una conversión. El botón de pagar no es sitio para descubrir que el módulo de pago no se descarga. Ahí conviene cargar por intención muy temprana, o directamente incluirlo, y el argumento no es de rendimiento sino de riesgo.
Ejecuta el panel de cobertura sobre tu ruta más pesada, con el recorrido completo de un usuario que no usa la funcionalidad avanzada. Ordena por bytes sin cubrir y coge los tres primeros. Para cada uno, mide su peso comprimido y estima el porcentaje de usuarios que lo usa con tu analítica. Corta el que tenga mejor producto de peso por fracción de usuarios que no lo usa, con precarga por intención, y mide el punto de entrada antes y después.