Precargar el fragmento antes de necesitarlo
La diferencia entre preload, prefetch y modulepreload, cuál corresponde a un fragmento de JavaScript, cómo se decide qué precargar sin desperdiciar datos, y por qué precargar de más puede empeorar la carga.
Trocear el código introduce una latencia nueva: el momento en que el usuario pide algo y hay que ir a buscarlo. La precarga elimina esa latencia adelantando la descarga a un instante en que no molesta. Es una técnica excelente y con un filo peligroso, porque los bytes que precargas compiten por el mismo ancho de banda que los que sí hacen falta ahora, y una precarga agresiva en una conexión mala retrasa lo importante para adelantar lo hipotético.
- Distinguir
preload,prefetchymodulepreloadpor prioridad y por semántica. - Elegir la pista correcta para un fragmento de JavaScript de ruta o de interacción.
- Decidir qué precargar con un criterio basado en probabilidad de uso.
- Detectar y corregir una precarga que está haciendo daño.
Las tres pistas y en qué se diferencian
Son tres mecanismos con nombres parecidos, prioridades muy distintas y semánticas incompatibles. Confundirlos es el error habitual.
| Pista | Prioridad | Semántica | Cuándo se usa |
|---|---|---|---|
rel="preload" |
Alta, compite con lo crítico | «Lo necesito en esta navegación, ya» | Fuente crítica, imagen del LCP |
rel="modulepreload" |
Alta | «Módulo ES que voy a necesitar en esta navegación» | Fragmentos de la ruta actual |
rel="prefetch" |
Muy baja, tiempo ocioso | «Puede que lo necesite en la siguiente navegación» | Fragmentos de la ruta siguiente probable |
La diferencia entre las dos primeras y la tercera es la que importa: preload y modulepreload compiten con los recursos de la carga actual; prefetch espera a que el navegador esté ocioso y puede no llegar a ejecutarse nunca si el usuario navega antes.
Y la diferencia entre preload y modulepreload es más sutil y más útil de lo que parece. <link rel="preload" as="script"> descarga el fichero y lo pone en la caché de memoria; nada más. <link rel="modulepreload"> hace tres cosas: descarga el fichero, lo parsea como módulo, y descarga recursivamente sus dependencias estáticas. Esa tercera parte es la que convierte una cascada de tres peticiones encadenadas en tres peticiones paralelas, y es la razón de que para módulos ES la pista correcta sea casi siempre modulepreload.
<!-- Correcto para un fragmento de modulo ES de la ruta actual -->
<link rel="modulepreload" href="/assets/chunks/Articulo-9f21ab.js">
<!-- Correcto para el fragmento de una ruta a la que probablemente vaya despues -->
<link rel="prefetch" href="/assets/chunks/Comentarios-3c81de.js" as="script">
<!-- Incorrecto para un modulo: descarga sin parsear ni resolver dependencias -->
<link rel="preload" href="/assets/chunks/Articulo-9f21ab.js" as="script">
Un detalle que rompe muchas precargas: modulepreload no admite el atributo as, y los módulos se descargan siempre en modo CORS. Si sirves los fragmentos desde un dominio distinto, necesitas el atributo crossorigin y las cabeceras correspondientes, o la precarga se descarta y el fichero se vuelve a descargar cuando se pide de verdad. El síntoma es inequívoco: en el panel de red aparecen dos peticiones al mismo fichero.
Precarga desde el código, no desde el HTML
Para fragmentos de interacción, insertar la pista desde JavaScript en el momento de la señal de intención es más flexible que ponerla en el HTML:
function precargarModulo(url) {
if (document.querySelector(`link[rel="modulepreload"][href="${url}"]`)) return;
const l = document.createElement('link');
l.rel = 'modulepreload';
l.href = url;
document.head.append(l);
}
Pero hay una alternativa mejor y más simple, que es llamar al propio import() y descartar el resultado:
const cargarEditor = () => import('./editor/EditorRico.js');
boton.addEventListener('pointerenter', () => {
cargarEditor(); // descarga, parsea, compila y evalua. La promesa se descarta.
}, { once: true, passive: true });
boton.addEventListener('click', async () => {
const { crearEditor } = await cargarEditor(); // ya resuelto: instantaneo
crearEditor(zona);
});
Funciona porque el registro de módulos garantiza una sola evaluación: la segunda llamada devuelve la misma promesa ya resuelta. Es más simple que manipular el head, no depende de conocer la URL con hash, y resuelve más trabajo por adelantado, porque además de descargar ejecuta el módulo.
Ese «además ejecuta» es exactamente su ventaja y su riesgo. Ventaja: cuando llega el clic, no queda ni parseo ni compilación ni evaluación, solo montar. Riesgo: la evaluación del módulo ocurre en el hilo principal en un momento que tú no controlas, y si el módulo hace trabajo pesado en su nivel superior, acabas de meter una tarea larga mientras el usuario mueve el ratón. Para módulos con nivel superior ligero —lo normal si has seguido lo de la compilación perezosa— es la mejor opción. Para módulos que construyen cosas al importarse, es mejor el modulepreload, que descarga y parsea sin evaluar.
Qué precargar: el criterio de probabilidad
El error de bulto es precargar todo. Cada fragmento precargado son bytes que el usuario paga con su tarifa de datos y ancho de banda que compite con lo que sí necesita ahora.
El criterio es una cuenta sencilla: precarga cuando la probabilidad de uso multiplicada por el ahorro de tiempo supera el coste esperado de los bytes desperdiciados. En la práctica, tres reglas que la aproximan bien:
Precarga con modulepreload, en la carga inicial, los fragmentos que la ruta actual va a necesitar sí o sí. Esto no es especulación, es corregir una cascada. Los empaquetadores modernos lo hacen solos si se lo pides.
Precarga con prefetch la ruta siguiente más probable, y solo una o dos. En un artículo, el siguiente artículo. En un listado, la ficha. En un formulario de varios pasos, el paso siguiente. Con datos de navegación reales, la ruta más probable suele acumular más del 50 por ciento de las transiciones, y eso justifica el gasto.
Precarga por intención cualquier cosa que dependa de un clic. Coste cero para quien no pasa el ratón, ahorro completo para quien sí.
Y una condición que hay que respetar siempre: no precargues en conexiones malas ni con ahorro de datos activo.
function convienePrecargar() {
const c = navigator.connection;
if (!c) return true; // sin informacion, asumimos que si
if (c.saveData) return false; // el usuario ha pedido ahorrar datos
if (/^(slow-)?2g$/.test(c.effectiveType)) return false;
return true;
}
if (convienePrecargar()) {
precargarRutaProbable();
}
La API de información de red está disponible en navegadores basados en Chromium; en los demás, navigator.connection es undefined y la función devuelve true, lo cual es el comportamiento correcto por defecto. saveData merece respeto: es una petición explícita del usuario, no una heurística.
El caso que hay que saber reconocer, porque el diagnóstico ingenuo lleva en la dirección contraria.
Una aplicación con troceado por rutas añade precarga de las seis rutas más visitadas para que las navegaciones sean instantáneas. Funcionan: las navegaciones internas van como un tiro. Y el LCP de la carga inicial empeora entre 300 y 900 milisegundos en móvil, sin que nadie haya tocado la página de inicio. El equipo mira el tiempo hasta el primer byte, mira la CDN, mira el servidor, y todo está igual.
Lo que ocurre es competencia por el ancho de banda. En una conexión de 1,6 Mbps efectivos, seis fragmentos de 40 KB son 240 KB que se están descargando al mismo tiempo que la imagen del LCP y la hoja de estilos. Aunque prefetch tenga prioridad muy baja en la cola del navegador, la prioridad ordena las peticiones pero no reserva ancho de banda: una vez que los bytes están en vuelo, comparten el tubo.
Tres señales que lo identifican en el panel de red, y conviene reconocerlas de memoria:
Las barras de descarga se solapan y todas se alargan. No hay una petición lenta; hay muchas peticiones que tardan más de lo que tardarían solas.
El LCP empeora en móvil y no en escritorio. Con 100 Mbps la competencia no se nota; con 1,6 sí. Si tus datos de laboratorio en escritorio están bien y los de campo en móvil empeoraron, sospecha de esto antes que del servidor.
Los fragmentos precargados aparecen con prioridad baja pero con tiempos de descarga largos. Es la firma exacta.
Las correcciones, por orden de eficacia:
Retrasa la precarga hasta después del evento de carga o hasta que el hilo esté ocioso. Es la corrección que resuelve el 90 por ciento de los casos y cuesta tres líneas.
function precargarCuandoOcioso(urls) {
const lanzar = () => {
for (const u of urls) {
const l = document.createElement('link');
l.rel = 'prefetch'; l.as = 'script'; l.href = u;
document.head.append(l);
}
};
if ('requestIdleCallback' in window) requestIdleCallback(lanzar, { timeout: 3000 });
else addEventListener('load', () => setTimeout(lanzar, 1500), { once: true });
}requestIdleCallback está en Chromium y en Firefox; Safari no lo implementa, y por eso el respaldo con load más un retardo no es opcional.
Reduce el número de rutas precargadas a una o dos. Seis rutas precargadas casi nunca están justificadas por los datos de navegación.
Y la comprobación definitiva: desactiva toda la precarga, mide el LCP en el dispositivo de referencia cinco veces, vuelve a activarla y repite. Si la mediana empeora, la precarga está costando más de lo que ahorra, y el ahorro que produce en navegaciones internas hay que ganárselo de otra forma.
Dejar que el empaquetador lo haga
Para los fragmentos de la ruta actual, la precarga correcta la puede generar la herramienta, que conoce el grafo completo y los nombres con hash. En Vite es el comportamiento por defecto: inserta modulepreload para las dependencias estáticas de cada fragmento dinámico, y ese es exactamente el caso donde la pista elimina una cascada real.
// vite.config.js — ajustar que se precarga, no si se precarga
export default {
build: {
modulePreload: {
polyfill: true, // navegadores sin soporte de modulepreload
resolveDependencies(url, deps, { hostId, hostType }) {
// Precargar solo las dependencias grandes; las minusculas no compensan
return deps.filter((d) => !d.includes('micro-'));
},
},
},
};
La cuestión práctica no es si activarlo —debe estar activado— sino comprobar en el panel de red que las pistas emitidas se están usando. Una pista que se emite y no se aprovecha genera el aviso del navegador sobre un recurso precargado que no se usó en pocos segundos, y ese aviso en la consola es la señal de que algo no cuadra: normalmente un crossorigin que falta o una URL que no coincide exactamente.
Mide las navegaciones internas de tu aplicación sin precarga: tiempo desde el clic hasta que la nueva vista está pintada, en el dispositivo de referencia. Añade prefetch de la ruta más probable y vuelve a medir esa transición, y también el LCP de la carga inicial. Si la transición mejora 300 milisegundos y el LCP empeora 200, la decisión depende de cuántos usuarios navegan y cuántos solo aterrizan, y ese dato lo tienes en la analítica.