Skip to content

Todo para WordPress, el desarrollo web — y mucho más

⚡ Cómo cargar JavaScript externo sin bloquear la página

⚡ Cómo cargar JavaScript externo sin bloquear la página

Cuando el navegador encuentra una etiqueta <script> sin atributos, lo deja todo. La renderización de la página se detiene por completo hasta que el script se carga y se ejecuta. En una conexión 4G lenta, eso son 2-3 segundos de pantalla en blanco.

Para entonces, el usuario ya se ha ido a la competencia. Core Web Vitals registran un LCP fallido, Google hunde la página en los resultados de búsqueda y usted pierde tráfico y conversiones. Mientras tanto, el problema se puede resolver con tres líneas si sabe dónde buscar.

A continuación, un método funcional para cargar JavaScript externo sin bloqueo. Desde el enfoque clásico de dos archivos hasta el moderno async/defer y el import() dinámico. Con código probado que puede copiar y pegar.

💡 Resumen rápido:

  • Entender el problema: cómo un <script> normal bloquea el análisis HTML y mata la velocidad de carga
  • Dominar el enfoque clásico: un minicargador (≤300 bytes) que obtiene dinámicamente el archivo JS principal
  • Aprender los atributos nativos async y defer: cuándo y cuál usar
  • Explorar el import() dinámico para cargar módulos bajo demanda
  • Elegir una estrategia para su proyecto con una tabla comparativa

Por qué JavaScript bloquea la renderización

Cuando el analizador HTML llega a <script src="app.js">, hace exactamente tres cosas: detiene el análisis del documento, descarga el archivo, lo ejecuta. Solo después continúa construyendo el DOM.

La razón es arquitectónica. Un script puede contener document.write(), que modifica el HTML sobre la marcha. El navegador no sabe de antemano si existe esa llamada, así que se cura en salud y espera la carga y ejecución completas. El resultado: incluso un script ligero de 5 KB añade cientos de milisegundos al First Contentful Paint solo por el viaje de ida y vuelta de la red.

El problema no es nuevo. En 2009, Nicholas Zakas describió una técnica de carga dinámica de JavaScript sin bloqueo, y aún funciona hoy, aunque con ajustes para las API modernas. Con la llegada de async, defer y los módulos ES, los desarrolladores disponen ahora de un conjunto completo de herramientas. Examinemos cada una.

Enfoque clásico: dos archivos y carga dinámica

La idea es simple. En lugar de poner todo su JS en un archivo y adjuntarlo a la página mediante <script src="...">, divida el código en dos partes:

  • Un minicargador (200-300 bytes tras compresión)
  • El archivo principal con la lógica de la aplicación

El cargador se inserta en línea al final de la página, justo antes de </body>. Crea un <script> programáticamente y lo añade al DOM; dicha etiqueta ya no bloquea el análisis porque aparece fuera del flujo principal del documento. En cuanto el archivo principal se carga, la inicialización se ejecuta.

Versión moderna de la función en JS puro sin retrocompatibilidad con IE:

1function loadScript(url) {
2 return new Promise((resolve, reject) => {
3 const script = document.createElement('script');
4 script.src = url;
5 script.onload = resolve;
6 script.onerror = reject;
7 document.head.appendChild(script);
8 });
9}

Nueve líneas. Sin comprobaciones de readyState, sin ramas para IE antiguo, sin pirámide de callbacks infernal. Solo una función que devuelve una Promise, cómoda de combinar con async/await.

El uso en la página tiene este aspecto (código al final, antes del cierre de </body>):

1<script>
2 function loadScript(url) {
3 return new Promise((resolve, reject) => {
4 const script = document.createElement('script');
5 script.src = url;
6 script.onload = resolve;
7 script.onerror = reject;
8 document.head.appendChild(script);
9 });
10 }
11
12 loadScript('/js/app.js').then(() => {
13 // Initialize after main file loads
14 App.init();
15 });
16</script>

El primer script (en línea) es el cargador. Se analiza y ejecuta instantáneamente porque ocupa menos de 300 bytes. El segundo script (app.js) se carga de forma asíncrona y no interfiere con la renderización.

¿Y si tiene más de dos archivos? Combínelos durante la compilación. Los empaquetadores modernos como Vite y Webpack hacen esto automáticamente: tree-shaking, división de código, minificación en una sola pasada. Gestionar manualmente el orden de carga de una docena de archivos es un camino directo a condiciones de carrera y errores.

Async y defer: desbloqueo nativo

HTML5 nos dio dos atributos que resuelven el problema sin una sola línea de JavaScript:

1<script async src="analytics.js"></script>
2<script defer src="app.js"></script>

Ambos cargan el archivo en paralelo con el análisis HTML. La diferencia está en el momento de ejecución:

Atributo

Carga

Ejecución

Orden

async

Paralela al análisis

Inmediatamente tras la carga

No garantizado

defer

Paralela al análisis

Tras el análisis HTML completo

Garantizado (como en el documento)

Regla general:

  • async para scripts independientes: analíticas, anuncios, contadores. No necesitan el DOM, no les importa el orden.
  • defer para la aplicación principal: manipulación del DOM, inicialización de la interfaz. El script espera a que la página esté lista y se ejecuta en la secuencia correcta.

En la práctica, la combinación es simple: ponga defer en todos los scripts del <head>, y se comportarán como si estuvieran al final de la página, pero cargarán antes. Sin magia, solo el planificador del navegador.

Y sí, puede combinarlo con la carga dinámica. Por ejemplo, cargue el núcleo de la aplicación mediante <script defer>, y adjunte widgets pesados dinámicamente a través de loadScript() solo cuando realmente se necesiten.

Import() dinámico: módulos bajo demanda

ES2020 trajo el import() dinámico, una forma nativa de cargar un módulo de forma asíncrona, sin empaquetador y sin funciones adicionales:

1// Loads only when user clicked
2button.addEventListener('click', async () => {
3 const { heavyChart } = await import('./chart-component.js');
4 heavyChart.render();
5});

La llamada import() devuelve una Promise. El módulo se carga en segundo plano, el análisis no se bloquea, la página sigue respondiendo. El código dentro del módulo se ejecuta en modo estricto y en su propio ámbito, se eliminan los conflictos de nombres.

Esta es la herramienta ideal para la división de código sin empaquetador. Los componentes pesados (gráficos, editores, mapas) se mueven a archivos separados y se cargan en la primera interacción. Un usuario que nunca abre un gráfico no paga por él con tráfico y tiempo de carga.

Comparación de enfoques

Cada método tiene su nicho. Para evitar conjeturas, recopilamos las características en una tabla:

Enfoque

Bloquea la renderización

Requiere JS

Orden de ejecución

Para qué scripts

<script src>

No

Garantizado

No se usa salvo que sea necesario

loadScript dinámico

No

Mediante cadena .then()

Carga condicional, dependencias pesadas

<script async>

No

No

No garantizado

Analíticas, anuncios, contadores

<script defer>

No

No

Garantizado

Aplicación principal, manipulación del DOM

import()

No

Sí (módulo ES)

Mediante await

División de código, componentes bajo demanda

Conclusión principal: no se obsesione con un único método. Una configuración típica de producción usa dos o tres simultáneamente: defer para el núcleo, async para métricas, import() dinámico para componentes pesados.

Breve vídeo demostrativo sobre el tema, desglose de async y defer con visualización de la línea de tiempo de carga:

⁉️🤔 Preguntas frecuentes

¿En qué se diferencia async de defer en la práctica?

Ambos no bloquean el análisis durante la carga. Pero async ejecuta el script inmediatamente después de que el archivo se cargue, incluso si el HTML aún no se ha analizado por completo, y sin garantía de orden. defer siempre espera a que el DOM esté completamente listo y preserva la secuencia de scripts tal como aparece en el HTML. Para el código de la aplicación principal, use defer; para contadores aislados, use async.

¿Se puede combinar la carga dinámica con defer?

Sí, es un escenario habitual. El núcleo de la aplicación se carga con defer en el <head>, e inicializa la interfaz. Los módulos pesados o de uso poco frecuente se obtienen mediante loadScript() dinámico o import() al interactuar el usuario. De esta forma, consigue tanto un arranque rápido como la carga diferida del código secundario.

¿Qué debo usar para un sitio WordPress?

WordPress añade automáticamente defer o async mediante wp_enqueue_script() si pasa el argumento apropiado en el quinto parámetro: wp_enqueue_script('my-script', $url, [], null, ['strategy' => 'defer']). Para scripts de terceros (Google Analytics, anuncios), el enfoque más sencillo es el atributo async. Los bloques interactivos complejos (calculadoras, filtros) deberían trasladarse a import() dinámico dentro de un módulo personalizado.

¿Funciona esto con scripts de terceros como Google Analytics?

Sí. La etiqueta gtag.js de GA4 se carga con async por defecto, por lo que no bloquea la página. Para otros servicios de terceros, consulte la documentación: si el script no requiere un DOM listo y no depende del orden de carga, no dude en usar async. Si necesita el DOM, use defer o carga dinámica con un callback.

¿Cómo verifico que un script realmente no bloquea la página?

Abra Chrome DevTools → Rendimiento → Grabar → recargue la página. En la línea de tiempo, busque bloques amarillos de "Scripting" antes del "First Contentful Paint" verde. Si un script se carga con defer o async, su ejecución estará después del FCP. Lighthouse en modo "Rendimiento" mostrará la recomendación "Eliminar recursos que bloquean la renderización"; no debería haber scripts en esa lista.

¿Vale la pena cambiar su enfoque de carga ahora mismo?

Si sus scripts aún cuelgan en el <head> sin atributos, está perdiendo posiciones en buscadores y molestando a los usuarios. Esto no es una hipótesis, Lighthouse y PageSpeed Insights muestran el problema en rojo en las primeras líneas del informe.

Inicio rápido: revise las etiquetas <script> de su plantilla, añada defer para el código principal y async para las métricas. Lleva cinco minutos, y el LCP puede mejorar entre 300 y 500 ms. Después, import() dinámico para componentes pesados cuando tenga ocasión de refactorizar.

Deje un único método de carga, <script defer> en el <head>, y la página cargará sin retrasos visibles. Pruébelo hoy en su proyecto.