wandres.dev
SCROLL-DRIVEN I · El modelo de timelines

Por qué el JavaScript de scroll siempre va por detrás

El desplazamiento se procesa en un hilo y tu manejador en otro. De ahí sale el retraso estructural, el jank y el reflujo forzado que ninguna optimización del manejador puede arreglar.

⏱ 18 min

Un efecto de desplazamiento escrito en JavaScript no va mal porque esté mal escrito. Va mal porque el desplazamiento lo procesa el compositor y tu código corre en el hilo principal, y entre los dos hay un viaje de ida y vuelta que cuesta al menos un frame incluso cuando todo va perfecto. Entender exactamente dónde está ese retraso explica por qué las optimizaciones habituales —agrupar en un frame, cachear medidas, marcar el escuchador como pasivo— mejoran las cosas sin llegar a resolverlas, y por qué la abstracción declarativa sí.

🎯 Al terminar esta lección sabrás
  • Explicar por qué el desplazamiento se procesa fuera del hilo principal.
  • Situar el evento scroll en el ciclo del frame y deducir el retraso mínimo.
  • Identificar el reflujo forzado que provoca medir dentro de un manejador.
  • Valorar qué resuelve y qué no resuelve IntersectionObserver.

Dónde ocurre el desplazamiento

Desplazar una página es, en un navegador moderno, una operación del compositor: mover capas ya pintadas y volver a componerlas. No necesita el hilo principal, y por eso una página puede seguir desplazándose con fluidez aunque un script largo lo tenga bloqueado. Es una de las mejores propiedades de la arquitectura y la razón por la que la web se siente razonable en un móvil.

El hilo principal se entera después, cuando el navegador le entrega un evento scroll. Ese evento no es una petición de permiso: es una notificación. El desplazamiento ya ha ocurrido y, en el caso normal, ya se ha compuesto un frame con la posición nueva.

De ahí sale el retraso mínimo, y es estructural, no un problema de eficiencia. Tu manejador se ejecuta describiendo una posición que la pantalla ya está mostrando. Cualquier estilo que escribas basado en esa posición se aplicará en el frame siguiente. El contenido va un paso por delante de tu efecto, siempre, aunque tu código tarde cero.

En un efecto sutil no se nota. En un elemento que debe mantenerse alineado con el contenido mientras se desplaza —un encabezado que se encoge, una capa de paralaje pegada a una imagen, un indicador que sigue a una sección— sí se nota, y el síntoma es que el elemento “flota” o “resbala” respecto de lo que acompaña.

Y encima, jank

Al retraso estructural se le suma el otro problema, que sí depende de lo que escribas: mientras tu manejador se ejecuta, el hilo principal no está haciendo otra cosa. Si el manejador tarda cinco milisegundos y hay más trabajo pendiente en ese frame, el frame se pasa de presupuesto.

Y el desplazamiento tiene la peculiaridad de que los eventos llegan muy seguidos: uno por frame como mínimo, y en algunos motores más de uno. Un manejador que cuesta cinco milisegundos se convierte en cinco milisegundos de cada dieciséis durante todo el rato que dure el gesto, que es cuando el usuario más está mirando.

El agrupamiento en requestAnimationFrame que todo el mundo escribe ayuda con esto, porque garantiza que el trabajo se hace una vez por frame y no una vez por evento, y porque lo coloca en el punto del ciclo donde escribir estilos es más barato. Pero el trabajo sigue estando en el hilo principal y sigue compitiendo con todo lo demás.

Hay que descartar de paso un malentendido muy extendido: marcar el escuchador de scroll como pasivo no acelera nada. La opción passive sirve para prometer que no vas a llamar a preventDefault(), y el evento scroll no es cancelable, así que la promesa no aporta información nueva. Donde passive sí importa muchísimo es en wheel, touchstart y touchmove, que sí son cancelables y donde un escuchador no pasivo obliga al navegador a esperar tu decisión antes de desplazar. Confundir ambos casos lleva a creer que un manejador de scroll pasivo es barato, y no lo es.

El reflujo forzado

El coste que más daño hace es el que menos se ve en el código. Medir el layout dentro de un manejador de desplazamiento fuerza al motor a calcular el layout en ese instante:

addEventListener('scroll', () => {
  const r = seccion.getBoundingClientRect();      // fuerza el calculo de layout
  const p = 1 - r.top / innerHeight;
  seccion.style.opacity = String(Math.max(0, Math.min(1, p)));  // lo invalida
}, { passive: true });

Dos líneas, y el patrón está roto. La lectura obliga a un layout sincrónico, la escritura lo invalida, y en el siguiente evento vuelve a empezar. Con un elemento cuesta poco; con veinte elementos en un bucle que lee y escribe alternando, cada iteración fuerza un layout completo del documento. Es la causa número uno de que un efecto de scroll hunda una página, y no aparece como una función lenta en el perfilador: aparece como tiempo de layout, que quien depura no atribuye a su propio código.

La corrección conocida es separar en dos fases: leer todo primero, calcular, y escribir todo después. Sirve, y sigue sin quitar el trabajo del hilo principal.

Lo que IntersectionObserver resuelve y lo que no

IntersectionObserver fue la primera respuesta seria de la plataforma a este problema y sigue siendo la herramienta correcta para lo suyo. El cálculo de intersección lo hace el navegador por su cuenta, sin que tú preguntes, y te entrega el resultado en una tarea aparte.

Lo que resuelve: saber si un elemento está visible sin medir nada, y hacerlo sin manejadores de desplazamiento. Para animaciones de entrada que se disparan una vez, es exactamente lo que hay que usar y no hace falta nada más.

Lo que no resuelve: darte progreso continuo. Los callbacks se disparan al cruzar umbrales, y aunque puedes declarar cien umbrales para aproximar una curva, los callbacks siguen llegando al hilo principal, siguen siendo asíncronos, y siguen sin garantizar uno por frame. Un paralaje construido sobre umbrales de intersección se ve peor que uno construido sobre el evento de desplazamiento, no mejor.

Es decir: IntersectionObserver resolvió la mitad binaria del problema y dejó la mitad continua sin resolver. La mitad continua es precisamente la que cubre el modelo de líneas de tiempo de progreso.

Nivel dios

El detalle que hace estructural el retraso y no accidental es que el evento scroll no se dispara cuando ocurre el desplazamiento, sino en un punto fijo del ciclo de actualización del frame. El navegador acumula los desplazamientos que hayan ocurrido y dispara los eventos durante la fase de actualización de la renderización, justo antes de los callbacks de animación. Eso tiene dos consecuencias contrarias que conviene tener claras. La buena: no hay que agrupar eventos porque el navegador ya los agrupa, y el patrón de la bandera con requestAnimationFrame que todo el mundo escribe está protegiendo contra una ráfaga que en un navegador moderno no llega. La mala: como el evento se dispara antes de los callbacks de animación en el mismo frame, escribir estilos directamente en el manejador de scroll sí que se aplica en ese frame, lo que hace que la versión ingenua se vea mejor de lo que merece y que la versión “optimizada” con requestAnimationFrame anidado se vea, a veces, un frame peor. Es de los pocos casos en la plataforma donde la optimización canónica puede empeorar la latencia percibida, y explica la discusión eterna de por qué el paralaje de fulano se ve más pegado que el de mengano aunque el código de mengano sea mejor. Ninguna de las dos versiones gana de verdad: las dos están del lado equivocado del viaje entre hilos.

Lo que la abstracción elimina

Puesto todo junto, el código de la barra de progreso de la lección anterior contenía: un escuchador de desplazamiento, un escuchador de redimensionado, una medida cacheada, una bandera de agrupamiento, un requestAnimationFrame, un cálculo de fracción con protección contra división por cero y una escritura de estilo. Siete piezas, cada una necesaria, y ninguna relacionada con el efecto que se quería conseguir.

Lo que la abstracción declarativa elimina no es una optimización: es esa lista entera. Queda el efecto, que es lo único que describía la intención, y la declaración de qué recorrido lo gobierna.

Y con la lista desaparece también su mantenimiento. Cada una de esas siete piezas es un sitio donde puede haber un bug, y varias de ellas —la medida cacheada que no se recalcula cuando cambia el contenido, el escuchador que no se retira al desmontar— son bugs que aparecen semanas después y que cuesta atribuir.

⚔️ Reto práctico

Instrumenta un manejador de desplazamiento con performance.now() al entrar y al salir, y registra el total acumulado durante diez segundos de desplazamiento continuo. Añade después un getBoundingClientRect() en medio y vuelve a medir. Compara los dos números con el presupuesto de un frame y calcula qué porcentaje del hilo principal estabas gastando en cada versión.