wandres.dev
CEDER EL HILO · yield, scheduler y las tareas

setTimeout de cero y por qué es la peor forma de ceder

El sujetado a cuatro milisegundos y de dónde viene, la pérdida de prioridad en la cola, la comparación medida entre las cuatro formas de ceder, y cuándo setTimeout sigue siendo la respuesta correcta.

⏱ 17 min

setTimeout(fn, 0) ha sido durante veinte años la forma de ceder el hilo en JavaScript, y funciona: la tarea actual termina, el navegador recupera el control, la continuación se ejecuta después. El problema es todo lo demás. Sufre un retardo mínimo impuesto por la especificación, pierde el turno frente a cualquier otro trabajo programado, y en un bucle de cien iteraciones ese coste acumulado convierte una operación de 400 milisegundos en una de varios segundos. Conviene entender exactamente por qué, porque es lo que justifica todo lo demás de este nivel.

🎯 Al terminar esta lección sabrás
  • Explicar el sujetado a cuatro milisegundos, cuándo se aplica y de dónde viene.
  • Cuantificar la diferencia entre las cuatro formas de ceder con una medición reproducible.
  • Reconocer cómo la pérdida de prioridad afecta a una página con terceros.
  • Identificar los casos donde setTimeout sigue siendo la herramienta correcta.

El sujetado a cuatro milisegundos

La especificación de HTML establece que, cuando el nivel de anidamiento de temporizadores supera cinco, el retardo se eleva a un mínimo de cuatro milisegundos. «Anidamiento» significa un setTimeout programado desde dentro de la devolución de llamada de otro, que es exactamente lo que hace un bucle troceado.

// A partir de la sexta iteracion, cada vuelta cuesta al menos 4 ms de espera
function procesarConTimeout(items, i = 0) {
  if (i >= items.length) return;
  procesar(items[i]);
  setTimeout(() => procesarConTimeout(items, i + 1), 0);
}

Con mil elementos, ese bucle paga cerca de cuatro segundos solo en esperas, con el hilo principal ocioso. El trabajo real puede ser de trescientos milisegundos.

El origen de la regla es histórico y razonable: en los años en que se fijó, había páginas con bucles de temporizadores que consumían el cien por cien de una CPU y agotaban la batería de portátiles. El sujetado fue una medida de protección, se implementó en todos los navegadores y se acabó escribiendo en la especificación para que el comportamiento fuese consistente.

Un matiz que sorprende: el sujetado no aplica a un setTimeout no anidado. Un temporizador programado desde un manejador de evento o desde el nivel superior se ejecuta con retardo casi nulo. El problema aparece solo en el encadenamiento, que es justo el patrón del troceado.

Otro matiz importante para no diagnosticar mal: en pestañas en segundo plano el sujetado sube a un segundo o más, y tras unos minutos la pestaña puede congelarse por completo. Un trabajo troceado con temporizadores que el usuario deja en otra pestaña puede tardar horas.

La pérdida de prioridad

El segundo problema es peor en páginas reales y menos conocido.

setTimeout encola en la cola de tareas normales. Esa cola es compartida con todo lo demás: la analítica de terceros, el gestor de etiquetas, el chat de soporte, la biblioteca de pruebas A/B. Cada uno de ellos programa temporizadores.

En una página con cinco scripts de terceros, tu continuación no es la siguiente tarea: es la que llegue cuando le toque. Si esos terceros han programado veinte devoluciones de llamada y cada una tarda 15 milisegundos, tu siguiente trozo empieza 300 milisegundos después de haber cedido.

Y ese coste es invisible en desarrollo, donde no hay terceros cargados, y aparece en producción de forma variable según qué scripts hayan terminado de cargar. Es una de las razones de que el rendimiento en producción se parezca tan poco al local.

scheduler.yield() no tiene este problema porque su continuación va en una cola de prioridad superior. MessageChannel tampoco tiene el sujetado, aunque sí comparte la cola normal.

La comparación, medida

Este banco se puede pegar en la consola de cualquier navegador y da los cuatro números en unos segundos:

async function medirCesion(nombre, cederFn, vueltas = 200) {
  const t0 = performance.now();
  for (let i = 0; i < vueltas; i++) {
    // Trabajo minimo: lo que medimos es el coste de ceder
    let x = 0; for (let j = 0; j < 1000; j++) x += j;
    await cederFn();
  }
  const total = performance.now() - t0;
  console.log(`${nombre.padEnd(22)} ${total.toFixed(0)} ms total, ${(total / vueltas).toFixed(2)} ms por cesion`);
}

const canal = new MessageChannel();
const colaMC = [];
canal.port1.onmessage = () => colaMC.shift()?.();
const cederMC = () => new Promise((r) => { colaMC.push(r); canal.port2.postMessage(null); });

await medirCesion('setTimeout(0)', () => new Promise((r) => setTimeout(r, 0)));
await medirCesion('MessageChannel', cederMC);
if (globalThis.scheduler?.postTask) {
  await medirCesion('scheduler.postTask', () => scheduler.postTask(() => {}, { priority: 'user-visible' }));
}
if (globalThis.scheduler?.yield) {
  await medirCesion('scheduler.yield', () => scheduler.yield());
}

Los órdenes de magnitud que se obtienen en un navegador de escritorio sin carga, y que conviene reproducir en tu propio entorno porque varían:

Mecanismo Coste por cesión Comentario
scheduler.yield() 0,1 - 0,4 ms Sin sujetado, con prioridad
scheduler.postTask() 0,2 - 0,6 ms Sin sujetado, con prioridad
MessageChannel 0,2 - 0,8 ms Sin sujetado, sin prioridad
setTimeout(fn, 0) 4 - 5 ms Sujetado a partir del sexto nivel

La diferencia de un orden de magnitud es real y se nota en cuanto hay más de unas decenas de cesiones. Y en una página con terceros activos, la columna de setTimeout se degrada mucho más que las otras tres, porque a los cuatro milisegundos se suma la espera en cola.

Cuándo setTimeout sigue siendo correcto

No es una API mala; es una API para otra cosa. Los usos legítimos:

Retardo real. Cuando de verdad quieres esperar un tiempo: mostrar un mensaje durante tres segundos, reintentar tras una pausa, agrupar por tiempo. Es exactamente su propósito.

Ceder una sola vez, no en bucle. Un único setTimeout(fn, 0) para dejar que el navegador pinte antes de una operación pesada no está anidado, no sufre sujetado, y es perfectamente razonable.

// Correcto: una cesion, no un bucle. Deja pintar el indicador antes de bloquear.
mostrarIndicador();
setTimeout(() => {
  const resultado = trabajoPesadoAtomico();
  mostrarResultado(resultado);
}, 0);

Como último respaldo de una cadena de degradación, cuando ni scheduler ni MessageChannel estén disponibles, cosa que hoy no ocurre en ningún navegador con soporte.

Y el uso que hay que erradicar: el bucle troceado con temporizadores encadenados. Ese patrón, escrito en miles de bases de código, es el que esta lección existe para sustituir.

requestAnimationFrame no es una forma de ceder, y usarlo como tal produce el peor de los dos mundos

Hay una cuarta alternativa que aparece constantemente en código real y que no está en la tabla porque no pertenece a la misma categoría: requestAnimationFrame. Merece un apartado porque el malentendido es muy común y las consecuencias son peores de lo que parece.

requestAnimationFrame no programa una tarea: programa una devolución de llamada dentro del ciclo de renderizado, justo antes de que el navegador calcule estilos y layout para el siguiente fotograma. Su propósito es actualizar el estado visual de una animación en el momento correcto.

Usarlo para trocear trabajo tiene tres problemas.

Uno: la frecuencia está atada al refresco de pantalla. Con una pantalla de 60 Hz, tu bucle avanza como mucho 60 veces por segundo, es decir, un trozo cada 16,7 milisegundos aunque el trozo tarde 2. Un bucle de mil trozos tarda 16 segundos como mínimo. Y en una pantalla de 120 Hz tarda la mitad, con lo que tu operación tiene una duración distinta según el monitor, que es un comportamiento que nadie quiere.

Dos: si la pestaña no es visible, no se ejecuta en absoluto. El navegador no pinta pestañas ocultas, así que no llama a las devoluciones de llamada de fotograma. Un trabajo troceado con rAF se detiene por completo cuando el usuario cambia de pestaña, y se reanuda al volver. A veces eso es lo que quieres —una animación no tiene sentido si nadie la ve— y para procesamiento de datos es un fallo.

Tres, y es el peor: el trabajo dentro de rAF retrasa directamente el fotograma. Estás ejecutando en el punto exacto del ciclo donde el navegador se dispone a pintar. Cien milisegundos de cálculo ahí son cien milisegundos de fotograma perdido, y con la API de fotogramas largos aparecen como tiempo de renderizado, no de tarea.

El uso correcto y muy valioso de rAF en este contexto es otro: agrupar escrituras al DOM en un único punto del ciclo, que es la base del patrón de leer primero y escribir después. Y el patrón de doble fotograma —requestAnimationFrame dentro de otro— sirve para ejecutar código después de que un pintado concreto haya ocurrido, que es lo que se necesita para medir tiempos de renderizado o para separar la respuesta visual del trabajo pesado, como se ve en el retraso de presentación.

// Correcto: hacer el cambio visual, esperar a que se pinte, y despues trabajar
elemento.classList.add('activo');
requestAnimationFrame(() => {
  requestAnimationFrame(() => {
    // Aqui el fotograma con 'activo' ya se presento
    hacerTrabajoPesado();
  });
});

Resumiendo la distinción, que es la que hay que llevarse: rAF es para sincronizar con el pintado; yield es para ceder el hilo. No son intercambiables, y usar el primero para lo segundo alarga tu operación por un factor de diez y la para cuando la pestaña se oculta.

⚔️ Reto práctico

Ejecuta el banco de las cuatro cesiones en Chromium, Firefox y Safari, y anota las doce cifras. Después busca en tu base de código los bucles troceados con setTimeout encadenado —grep -rn "setTimeout" src/ | grep -i "recur\|siguiente\|next\|chunk" es un buen punto de partida— y convierte el más largo a la función ceder con degradación. Mide el tiempo total de la operación antes y después: en bucles de más de cien iteraciones, la diferencia suele ser de segundos.