Portal Performance
eventos

Scroll que dispara cientos de veces

El evento scroll se emite decenas de veces por segundo. Cómo cortarlo bien para trabajo visual.

El problema

Una barra de progreso de lectura. Cada vez que el usuario hace scroll, calculas qué porcentaje del artículo ha visto y pintas el ancho de la barra. Simple.

El problema no es el cálculo, es la frecuencia. El evento scroll se dispara a ritmo del sistema — decenas o cientos de veces por segundo — y cada disparo lee scrollHeight, que fuerza al navegador a recalcular layout, y escribe a style.width, que fuerza repintado. Sumado, roba suficiente tiempo del hilo principal como para que el propio scroll deje de ser suave.

El código “roto”

const barra = document.getElementById("progreso");

window.addEventListener("scroll", () => {
  const total = document.documentElement.scrollHeight - window.innerHeight;
  const leido = window.scrollY;
  const porcentaje = (leido / total) * 100;
  barra.style.width = porcentaje + "%";
});

Las pistas

Ver la solución
const barra = document.getElementById("progreso");
let stop = false;

window.addEventListener("scroll", () => {
  if (stop) return;
  stop = true;

  const total = document.documentElement.scrollHeight - window.innerHeight;
  const leido = window.scrollY;
  const porcentaje = (leido / total) * 100;
  barra.style.width = porcentaje + "%";

  setTimeout(() => {
    stop = false;
  }, 200);
});

Este es el patrón throttle clásico: una bandera bloquea el listener tras cada ejecución, y un setTimeout la libera pasados 200ms. El resultado es que, como mucho, se ejecuta cinco veces por segundo, independientemente de cuánto haga scroll el usuario. Funciona, y es fácil de leer, pero tiene un pequeño defecto: si el usuario para justo entre disparos, la barra puede quedarse un pelín por debajo del porcentaje real. Para una barra de progreso no es dramático; para guardar una posición exacta, sí.

La versión fina para trabajo visual es requestAnimationFrame:

const barra = document.getElementById("progreso");
let pendiente = false;

window.addEventListener("scroll", () => {
  if (pendiente) return;
  pendiente = true;

  requestAnimationFrame(() => {
    const total = document.documentElement.scrollHeight - window.innerHeight;
    const porcentaje = (window.scrollY / total) * 100;
    barra.style.width = porcentaje + "%";
    pendiente = false;
  });
});

requestAnimationFrame le dice al navegador: “ejecuta esto justo antes del próximo repintado”. El callback corre una sola vez por frame, ni una de más, y si la pestaña no es visible se pausa automáticamente. Para cualquier cosa que afecte al pintado, es la opción correcta.

Este es el script que gobierna la barra de progreso real del sitio, si te fijas en la parte de arriba.

Pruébalo

Scroll sin control vs con requestAnimationFrame

Dos contenedores con las mismas 200 líneas. El modo sin throttle llama al listener en cada evento scroll — decenas o cientos de veces por segundo — y cada llamada lee scrollHeight (forzando layout) y escribe style.width. El modo con rAF deja al navegador marcar el ritmo: una ejecución por frame, ni una de más. Scrollea con el ratón o con la rueda y mira la stat "Updates a la barra": es la métrica que revela la diferencia.

Sin throttle · layout + style en cada scroll
Disparos: 0 · Updates a la barra: 0
Con rAF · un update por frame
Disparos: 0 · Updates a la barra: 0

Comprueba lo que has aprendido

  1. 1. ¿Por qué debounce no vale para actualizar una barra de progreso de scroll?

  2. 2. ¿Cuál es la diferencia entre throttle y `requestAnimationFrame` para este caso?

  3. 3. ¿Qué gana usar `requestAnimationFrame` frente a `setTimeout(cb, 16)` para trabajo visual?

  4. 4. ¿Qué problema tiene la versión throttle con bandera y `setTimeout(fn, 200)` para una barra de progreso?

  5. 5. El listener de scroll del código roto puede dispararse cien veces por segundo. ¿Qué implica en la práctica?