El JavaScript bloqueante y el parser
Por qué un script síncrono detiene la construcción del DOM, qué justificaba ese diseño, y el coste real de cada script en el head medido con precisión.
Un script sin atributos de carga colocado en el <head> detiene el parser en seco: el navegador deja de construir el DOM, descarga el script, lo ejecuta, y solo entonces continúa. Es el comportamiento por defecto y es casi siempre el peor posible. La razón por la que se diseñó así sigue siendo válida, y por eso la solución no es cambiar el valor por defecto sino declarar explícitamente la intención.
- Explicar qué justifica que un script síncrono detenga el parser.
- Cuantificar el coste de un script bloqueante en la ruta crítica.
- Reconocer los tres patrones legítimos de script síncrono que quedan.
- Medir el tiempo que el parser pasa detenido en una carga real.
Por qué el parser se detiene
El motivo es que un script puede modificar el documento mientras se está parseando. El mecanismo que lo permite es una función de la plataforma que escribe directamente en el flujo del documento en el punto donde está el parser:
<p>Antes</p>
<script>
document.write('<p>Insertado por el script</p>');
</script>
<p>Despues</p>
Ese fragmento produce tres párrafos en orden, porque el script escribe en el punto exacto donde el parser estaba. Para que eso funcione, el parser tiene que detenerse: no puede seguir construyendo el DOM si lo que viene a continuación depende de lo que el script decida escribir.
Esa función está desaconsejada desde hace años y los navegadores la restringen activamente, pero la semántica de bloqueo se mantiene por compatibilidad. Hay millones de páginas que dependen de que un script se ejecute antes de que el parser continúe, y romperlo no es una opción.
La consecuencia es la regla operativa: un script sin async ni defer bloquea el parser, siempre, aunque no use ninguna función de escritura.
Y hay un segundo bloqueo encadenado: como el script puede consultar estilos calculados, tampoco puede ejecutarse hasta que el CSSOM esté completo. Un script síncrono situado después de una hoja de estilos espera a la hoja, y el parser espera al script.
El coste real
Vamos a ponerle números. Un script síncrono en el <head>, con la conexión ya abierta al origen, en una red de 150 ms de RTT y 1,6 Mbps, en un dispositivo con el multiplicador de CPU de un móvil de gama media:
El parser encuentra la etiqueta script t = 0
Peticion y primer byte +150 ms
Transferencia de 40 KB comprimidos +200 ms
Parseo y compilacion en gama media +120 ms
Ejecucion +80 ms
---------
El parser reanuda +550 ms
Medio segundo con el parser detenido, sin construir DOM, sin poder pintar nada. Y ese coste se suma al primer pintado, que ya estaba esperando al CSS.
Con dos scripts síncronos, el coste se duplica, porque se ejecutan en orden estricto: el segundo no puede empezar hasta que el primero termine. Con tres, se triplica. La escalada es lineal y despiadada.
Hay un matiz importante que suaviza la primera parte del cálculo: el escáner de precarga habrá visto la etiqueta y empezado a descargar el script antes de que el parser llegue a él. Eso ahorra el tiempo de descarga en el caso favorable. Lo que no ahorra en ningún caso es el tiempo de parseo, compilación y ejecución, que ocurre necesariamente cuando el parser llega y con el parser detenido.
Un script síncrono alojado en un dominio ajeno añade a lo anterior la cadena completa de establecimiento: resolución de nombre, conexión y negociación segura. Son tres o cuatro viajes de ida y vuelta más, con el parser detenido todo el tiempo. En la red de referencia, un solo script de terceros síncrono en el <head> puede costar más de un segundo de retraso del primer pintado. Es el patrón que produce los peores números de la web, y sigue siendo la instalación por defecto que documentan muchos proveedores de analítica y de pruebas A/B.
Los tres casos legítimos que quedan
Ser absolutista aquí sería impreciso. Hay tres situaciones en las que un script tiene que ejecutarse antes de que el navegador siga, y conviene reconocerlas.
Uno: evitar un parpadeo de personalización. Una prueba A/B o un tema oscuro aplicado por JavaScript necesita ejecutarse antes del primer pintado, o el usuario verá la versión por defecto y luego el cambio. La solución correcta no es un script bloqueante externo, es un fragmento incrustado y minúsculo en el <head>:
<head>
<script>
// Aplica el tema guardado antes del primer pintado.
// Debe ser minusculo y sincrono a proposito.
try {
const t = localStorage.getItem('tema');
if (t) document.documentElement.dataset.tema = t;
} catch {}
</script>
</head>
Incrustado significa que no hay petición de red: el coste es solo la ejecución, unos microsegundos. La documentación de optimización de LCP recomienda exactamente esto para el código que debe correr pronto: incrustarlo, y solo si es muy pequeño.
Dos: polyfills que deben existir antes de que cargue todo lo demás. Cada vez menos relevante, y si hace falta, se sirve condicionalmente para que solo lo reciban los navegadores que lo necesitan.
Tres: código de medición que debe capturar eventos tempranos. Un contraejemplo instructivo: la librería web-vitals no necesita esto, porque usa el mecanismo que le permite acceder a entradas anteriores a su propia carga. La mayoría de los casos que parecen necesitar ejecución temprana tienen una solución equivalente.
Fuera de esos tres casos, todo script externo debería llevar defer o async, y el que no lo lleve debería tener un comentario explicando por qué.
Medir el tiempo de parser detenido
No hay una marca directa, pero se puede aproximar bastante bien.
// Aproximacion del tiempo que el parser paso detenido por scripts.
const nav = performance.getEntriesByType('navigation')[0];
const bloqueantes = performance.getEntriesByType('resource')
.filter((r) => r.initiatorType === 'script' && r.responseEnd < nav.domInteractive)
.map((r) => ({
script: r.name.split('/').pop().slice(0, 40),
origen: new URL(r.name).origin,
inicio: Math.round(r.startTime),
fin: Math.round(r.responseEnd),
ms: Math.round(r.duration),
bytes: r.encodedBodySize,
}))
.sort((a, b) => a.inicio - b.inicio);
console.table(bloqueantes);
console.log('domInteractive:', Math.round(nav.domInteractive), 'ms');
Los scripts que terminan de descargarse antes de que el DOM esté interactivo son candidatos a estar en la ruta crítica. Si la suma de sus duraciones se aproxima al hueco entre el final de la descarga del HTML y domInteractive, tienes tu respuesta.
La comprobación complementaria, y más directa, es simplemente leer el código fuente del documento:
# Contar scripts sincronos en el head.
curl -s https://ejemplo.com \
| sed -n '/<head/,/<\/head>/p' \
| grep -o '<script[^>]*>' \
| grep -v -E 'defer|async|type="module"|type="application/json"'
Cada línea que salga de ahí es un punto de detención del parser. Si sale alguna con un dominio ajeno, ese es el primer sitio donde mirar.
Puedes tener el <head> impecable, con todo diferido, y aun así estar pagando bloqueos del parser. El mecanismo es este: un gestor de etiquetas cargado de forma asíncrona ejecuta código que crea elementos de script y los inserta en el documento. Los scripts insertados por programa se comportan como asíncronos por defecto, así que no bloquean el parser, y ahí no está el problema. El problema es que muchos de esos scripts, a su vez, cargan otros desde otros dominios, y el conjunto compite por el hilo principal y por el ancho de banda justo durante la ventana en que se está intentando pintar. He medido cascadas de cinco niveles de scripts que se cargan unos a otros, iniciadas a los 400 milisegundos y terminadas a los 3.200, que no aparecen en ninguna auditoría del código fuente porque en el HTML solo hay una etiqueta. La forma de verlo es ordenar todos los recursos de tipo script por instante de inicio y mirar cuántos empiezan después del primer segundo: esos no estaban en tu HTML, los ha traído alguien. Y la forma de arreglarlo no es técnica sino de gobierno: alguien tiene que poder decir que no a la quinta etiqueta.