
⚡ 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
asyncydefer: 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:
1 function 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 |
|---|---|---|---|
| Paralela al análisis | Inmediatamente tras la carga | No garantizado |
| Paralela al análisis | Tras el análisis HTML completo | Garantizado (como en el documento) |
Regla general:
asyncpara scripts independientes: analíticas, anuncios, contadores. No necesitan el DOM, no les importa el orden.deferpara 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 2 button.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 |
|---|---|---|---|---|
| Sí | No | Garantizado | No se usa salvo que sea necesario |
| No | Sí | Mediante cadena | Carga condicional, dependencias pesadas |
| No | No | No garantizado | Analíticas, anuncios, contadores |
| No | No | Garantizado | Aplicación principal, manipulación del DOM |
| No | Sí (módulo ES) | Mediante | 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
asyncejecuta 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.defersiempre 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, usedefer; para contadores aislados, useasync.
¿Se puede combinar la carga dinámica con defer?
Sí, es un escenario habitual. El núcleo de la aplicación se carga con
deferen el<head>, e inicializa la interfaz. Los módulos pesados o de uso poco frecuente se obtienen medianteloadScript()dinámico oimport()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
deferoasyncmediantewp_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 atributoasync. Los bloques interactivos complejos (calculadoras, filtros) deberían trasladarse aimport()dinámico dentro de un módulo personalizado.
¿Funciona esto con scripts de terceros como Google Analytics?
Sí. La etiqueta
gtag.jsde GA4 se carga conasyncpor 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 usarasync. Si necesita el DOM, usedefero 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
deferoasync, 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.



