wandres.dev
LA RUTA CRÍTICA · Qué bloquea el primer pintado

defer, async y módulos: el orden de ejecución

Las cinco formas de cargar un script, qué garantiza cada una sobre el orden y el momento de ejecución, y cuál elegir según lo que el código necesite.

⏱ 17 min

Hay cinco formas de declarar la carga de un script y cada una da garantías distintas sobre cuándo se ejecuta y en qué orden respecto a los demás. Elegir mal produce dos clases de fallo: uno de rendimiento, si bloqueas sin necesidad, y uno de corrección, si tu código depende de un orden que no has pedido. La tabla completa cabe en una pantalla y merece memorizarse.

🎯 Al terminar esta lección sabrás
  • Enumerar las cinco formas de carga y sus garantías exactas.
  • Predecir el orden de ejecución de un conjunto de scripts mezclados.
  • Elegir la forma correcta según las dependencias del código.
  • Reconocer los casos donde el módulo cambia la semántica sin avisar.

Las cinco formas

Uno: script clásico sin atributos. El parser se detiene, descarga, ejecuta y continúa. El orden entre varios es el del documento y está garantizado.

<script src="/js/uno.js"></script>

Dos: async. Se descarga en paralelo sin detener el parser, y se ejecuta en cuanto está disponible, interrumpiendo el parser en ese momento. El orden entre varios scripts asíncronos no está garantizado: se ejecutan según terminen de descargarse, y eso depende del tamaño de cada uno y de la red.

<script src="/js/analitica.js" async></script>

Tres: defer. Se descarga en paralelo sin detener el parser, y se ejecuta cuando el parseo ha terminado, justo antes del evento de contenido cargado. El orden entre varios está garantizado y es el del documento.

<script src="/js/app.js" defer></script>

Cuatro: módulo. Un script de tipo módulo es diferido por defecto: se comporta como si llevara defer aunque no lo lleve. Sus dependencias se resuelven y se descargan formando un grafo, y todo el grafo se ejecuta después del parseo, en orden. Esto aplica también a los módulos incrustados, que es la diferencia más sorprendente frente a los scripts clásicos incrustados, que se ejecutan al instante.

<script type="module" src="/js/app.js"></script>
<script type="module">
  // Este tambien es diferido, aunque este incrustado.
  import { iniciar } from '/js/inicio.js';
  iniciar();
</script>

Cinco: módulo con async. Se ejecuta en cuanto el grafo de dependencias está listo, sin esperar al parseo. Sin garantía de orden.

<script type="module" src="/js/widget.js" async></script>

La tabla de garantías

Forma ¿Bloquea el parser al descargar? ¿Cuándo se ejecuta? ¿Orden garantizado?
Clásico Al llegar el parser Sí, el del documento
async No En cuanto se descarga No
defer No Tras el parseo, antes del evento de contenido cargado Sí, el del documento
Módulo No Tras el parseo, antes del evento de contenido cargado Sí, el del documento
Módulo con async No En cuanto el grafo está listo No

Dos precisiones que la tabla no captura y que importan.

Los scripts con defer y los módulos comparten cola y respetan el orden entre sí. Si mezclas un defer clásico y un módulo, se ejecutan en el orden en que aparecen en el documento.

El atributo async sobre un script clásico gana a defer. Si pones los dos, se comporta como async. La combinación se usa como respaldo para navegadores muy antiguos y hoy no tiene sentido.

Y una tercera que produce errores sutiles: un script insertado por programa se comporta como asíncrono por defecto. Si tu código crea un elemento de script y lo añade al documento, no hereda el defer de nadie. Para forzar el orden hay que desactivarlo explícitamente:

const s = document.createElement('script');
s.src = '/js/dependiente.js';
s.async = false;          // fuerza orden respecto a otros insertados igual
document.head.append(s);

Cuál elegir

El árbol de decisión es corto.

¿El script necesita ejecutarse antes del primer pintado para evitar un parpadeo? Incrústalo, minúsculo, síncrono. Es el único caso.

¿El script necesita el DOM completo, o depende de otros scripts? Usa defer. Es el valor por defecto sensato para el código de tu aplicación.

¿El script es completamente independiente y no le importa cuándo se ejecute? Usa async. El caso típico es la analítica.

¿Usas sintaxis de módulos? El tipo módulo ya es diferido; no hace falta añadir defer, que además se ignora.

¿El script es un widget aislado que debe aparecer cuanto antes? Módulo con async.

La consecuencia práctica más importante es que defer debería ser tu valor por defecto para todo lo que sea código de la aplicación. Preserva el orden, no bloquea el parser, y garantiza que el DOM existe cuando se ejecuta. Los tres problemas que la gente resuelve poniendo el script al final del <body> los resuelve defer mejor, porque además permite que la descarga empiece antes.

⚠️
El error de orden que produce fallos intermitentes

Poner async en scripts que dependen unos de otros es la causa más común de errores que solo aparecen a veces. Con la red rápida, el orden de descarga coincide con el del documento y todo funciona; con la red lenta o con la caché en un estado distinto, el orden cambia y el segundo script se ejecuta antes que el primero. El fallo no se reproduce en desarrollo y aparece en producción de forma aparentemente aleatoria. Si dos scripts tienen cualquier dependencia entre ellos, ninguno de los dos puede llevar async.

Un ejemplo de orden mezclado

Para consolidar. Dado este documento, ¿en qué orden se ejecuta todo?

<head>
  <script src="/a.js"></script>
  <script src="/b.js" async></script>
  <script src="/c.js" defer></script>
  <script type="module" src="/d.js"></script>
  <script src="/e.js" defer></script>
</head>
<body>
  <script>console.log('f incrustado');</script>
</body>

La respuesta:

  1. a.js primero, siempre. Es síncrono y detiene el parser en su posición.
  2. f, el incrustado, cuando el parser llega a él, es decir, antes de terminar el parseo. Un script clásico incrustado se ejecuta al instante.
  3. b.js en algún momento entre los dos anteriores o después, sin garantía: se ejecuta en cuanto termine de descargarse, que puede ser antes o después de que el parser llegue al final.
  4. c.js, luego d.js, luego e.js, en ese orden, después de terminar el parseo. Los tres comparten cola y respetan el orden del documento aunque uno sea un módulo.

El punto 3 es el que rompe las expectativas de todo el mundo, y el 2 el que sorprende a quien cree que el incrustado del <body> va al final.

Detalles de los módulos que cambian la semántica

Usar el tipo módulo no es solo cambiar cómo se carga: cambia varias cosas del entorno de ejecución, y conviene saberlo antes de migrar.

  • El modo estricto está siempre activo. No es opcional.
  • El ámbito es del módulo, no global. Una variable declarada en el nivel superior no se convierte en propiedad del objeto global. Código antiguo que dependía de eso se rompe.
  • Se aplican las reglas de compartición entre orígenes. Un módulo servido desde otro dominio necesita las cabeceras correspondientes; un script clásico no.
  • Cada módulo se ejecuta una sola vez aunque se importe desde varios sitios.
  • Se puede usar await en el nivel superior, lo cual retrasa la ejecución de los módulos que dependen de él.

Esa última característica tiene una implicación de rendimiento poco obvia: un await en el nivel superior de un módulo que espera a una petición de red bloquea la ejecución de todo el grafo que depende de él. Es cómodo y puede añadir cientos de milisegundos si se usa en el camino de arranque.

El atributo async en el script de analítica es correcto y tiene un efecto secundario que arruina el INP y que nadie atribuye a esa decisión

Poner async en la analítica es la recomendación estándar y es correcta desde el punto de vista de la carga: no bloquea el parser y no retrasa el primer pintado. El efecto secundario está en la otra fase. Un script asíncrono se ejecuta en cuanto termina de descargarse, y ese instante es impredecible: puede caer justo en el momento en que el usuario hace su primer clic. Como la ejecución de un script grande es una tarea que ocupa el hilo principal, el evento del usuario se encola detrás, y esa interacción registra un retraso de entrada de varios cientos de milisegundos. Es una de las causas más frecuentes de INP malo con loadState en fase de carga, y casi nunca se atribuye al script de analítica porque ese script no bloquea nada visible y la métrica de carga está perfecta. La solución no es quitar async sino retrasar la ejecución a un momento en que el usuario ya no esté empezando: cargar la analítica cuando la página esté ociosa, o tras el primer desplazamiento, o pasados unos segundos. Se pierde una fracción de eventos de sesiones muy cortas y se recupera la responsividad del arranque, que es donde ocurren las peores interacciones.