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

scheduler.postTask() y las tres prioridades de tarea

Qué significa cada una de las tres prioridades, cómo se relacionan con la cola del navegador, cómo se cancela y se cambia la prioridad de una tarea en vuelo, y qué hacer en los navegadores que no la implementan.

⏱ 18 min

Todo lo que programas con setTimeout compite en un único nivel de prioridad, y el navegador no tiene forma de saber que la actualización del contador del carrito importa más que el envío de una métrica. scheduler.postTask() introduce tres niveles explícitos y una API de control —cancelación y cambio de prioridad en vuelo— que convierte la planificación en algo que se diseña en lugar de algo que ocurre. Tiene el mismo problema de disponibilidad que el resto de la familia: no está en Safari.

🎯 Al terminar esta lección sabrás
  • Describir las tres prioridades y dónde encaja cada una respecto al renderizado.
  • Programar, cancelar y reordenar tareas con la API completa.
  • Elegir la prioridad correcta para cada tipo de trabajo diferido.
  • Escribir la degradación para navegadores sin la API.

Las tres prioridades

scheduler.postTask(callback, opciones) acepta una prioridad entre tres valores, y devuelve una promesa con el valor de retorno de la devolución de llamada.

user-blocking. La más alta. Trabajo que el usuario está esperando activamente y que bloquea su progreso: responder a una interacción, actualizar lo que acaba de tocar. Se ejecuta antes que cualquier otra cosa programada.

user-visible. La prioridad por defecto. Trabajo cuyo resultado el usuario va a ver pero no está esperando con el dedo encima: renderizar la parte inferior de una lista, actualizar un panel secundario, preparar la siguiente vista.

background. La más baja. Trabajo que el usuario no ve: registro, telemetría, precálculo, limpieza de cachés, indexado. Se ejecuta cuando no hay nada mejor que hacer.

// Responder al usuario: lo antes posible
scheduler.postTask(() => actualizarContadorCarrito(), { priority: 'user-blocking' });

// Preparar lo que se verá después
scheduler.postTask(() => renderizarRecomendaciones(), { priority: 'user-visible' });

// Nada de esto lo ve nadie
scheduler.postTask(() => enviarTelemetria(), { priority: 'background' });

La relación con el ciclo de renderizado es la que da sentido a los nombres. Las tareas user-blocking se ejecutan antes de que el navegador considere pintar, para que el resultado entre en el mismo fotograma. Las background se ejecutan cuando el navegador está ocioso, con un perfil parecido al de las devoluciones de llamada de tiempo libre.

Cancelar y reordenar

Esta es la parte que ninguna alternativa clásica ofrece, y es la que más rinde en aplicaciones interactivas.

Cancelar se hace con una señal de aborto estándar, la misma que usas con fetch:

const control = new AbortController();

scheduler.postTask(() => calcularSugerencias(consulta), {
  priority: 'user-visible',
  signal: control.signal,
}).catch((e) => {
  if (e.name !== 'AbortError') throw e;    // cancelar no es un error real
});

// El usuario sigue escribiendo: la sugerencia anterior ya no vale
control.abort();

El patrón resuelve de raíz un problema clásico de las búsquedas incrementales: en lugar de limitar la frecuencia con un temporizador y esperar, programas cada consulta y cancelas la anterior. El trabajo obsoleto no llega a ejecutarse.

Cambiar la prioridad en vuelo se hace con un controlador especializado. Es útil cuando algo que era secundario pasa a ser lo que el usuario está mirando:

const control = new TaskController({ priority: 'background' });

scheduler.postTask(() => precargarDetalle(id), { signal: control.signal });

// El usuario ha abierto justo ese detalle: ahora es urgente
tarjeta.addEventListener('click', () => {
  control.setPriority('user-blocking');
});

TaskController extiende AbortController, así que la misma señal sirve para cancelar y para reordenar. Esta capacidad no tiene equivalente con temporizadores: una vez programado un setTimeout, lo único que puedes hacer es cancelarlo y volver a programarlo, perdiendo el orden.

Y hay un tercer parámetro, delay, que da el equivalente de setTimeout con prioridad:

scheduler.postTask(() => guardarBorrador(), { priority: 'background', delay: 2000 });

Qué prioridad para qué trabajo

La tabla que uso como referencia:

Trabajo Prioridad Motivo
Actualizar el elemento que el usuario acaba de tocar user-blocking Está esperando
Validar un campo tras salir de él user-blocking Respuesta inmediata esperada
Renderizar contenido fuera del viewport user-visible Lo verá al desplazarse
Precargar la siguiente ruta probable background Especulativo
Enviar métricas y eventos background Invisible
Refrescar un dato que cambia solo user-visible Lo ve, no lo espera
Indexar para búsqueda local background Invisible hasta que busca

El error más frecuente es usar user-blocking para todo, con el mismo razonamiento que lleva a poner fetchpriority="high" en diez imágenes: si todo es urgente, nada lo es, y además has metido en la cola prioritaria trabajo que retrasa la respuesta real a la interacción.

El segundo error, menos obvio: usar background para trabajo que el usuario sí va a esperar. Una tarea de fondo puede tardar mucho en ejecutarse si la página está ocupada, y en una pestaña en segundo plano puede no ejecutarse en absoluto durante minutos. Si hay un límite de tiempo real, background no es la prioridad.

La degradación

Sin la API, hay que aproximar. La degradación honesta reconoce que las prioridades no se pueden simular bien, y se limita a acertar en el comportamiento general:

const tienePostTask = typeof globalThis.scheduler?.postTask === 'function';

export function programar(fn, { priority = 'user-visible', signal, delay = 0 } = {}) {
  if (tienePostTask) {
    return globalThis.scheduler.postTask(fn, { priority, signal, delay });
  }
  return new Promise((resolver, rechazar) => {
    const cancelar = () => rechazar(new DOMException('Aborted', 'AbortError'));
    if (signal?.aborted) return cancelar();
    signal?.addEventListener('abort', cancelar, { once: true });

    const ejecutar = () => {
      if (signal?.aborted) return;
      try { resolver(fn()); } catch (e) { rechazar(e); }
    };

    if (priority === 'background' && 'requestIdleCallback' in globalThis) {
      const id = requestIdleCallback(ejecutar, { timeout: 3000 });
      signal?.addEventListener('abort', () => cancelIdleCallback(id), { once: true });
    } else {
      const id = setTimeout(ejecutar, delay);
      signal?.addEventListener('abort', () => clearTimeout(id), { once: true });
    }
  });
}

Lo que esta degradación consigue: la cancelación funciona, el retardo funciona, y background se aproxima con las devoluciones de llamada de tiempo libre donde existen. Lo que no consigue: la distinción entre user-blocking y user-visible, que se colapsa en una sola cola. Es la limitación real y no hay forma de rodearla.

requestIdleCallback está en Chromium y en Firefox; Safari no lo implementa, así que ahí todo cae a setTimeout y la degradación es más pobre todavía. Es coherente con lo que ya sabes del estado de esta familia de APIs.

Las tareas de fondo en una pestaña oculta se congelan, y ahí es donde se pierde la telemetría

Hay un comportamiento de los navegadores que interactúa con las prioridades de forma poco documentada y que rompe la instrumentación de mucha gente.

Cuando una pestaña pasa a segundo plano, los navegadores aplican políticas de ahorro agresivas: los temporizadores se sujetan a un mínimo de un segundo, y tras unos minutos la pestaña puede entrar en un estado de congelación donde no se ejecuta nada en absoluto. En móvil ese estado llega antes y es más estricto, y la pestaña puede además ser descartada por completo, momento en el que todo tu estado en memoria desaparece.

Las tareas con prioridad background son las primeras víctimas: programadas en una pestaña que el usuario acaba de abandonar, es perfectamente posible que no se ejecuten nunca. Si tu envío de métricas es una tarea de fondo, has perdido las métricas de exactamente las sesiones que más te interesan: las que el usuario abandonó.

Las tres reglas que resuelven esto.

Uno: el envío de datos importantes no es trabajo de fondo, es trabajo del evento de ocultación. El sitio correcto es visibilitychange a estado oculto, y el mecanismo es sendBeacon, que el navegador se compromete a entregar aunque la página muera:

addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    navigator.sendBeacon('/telemetria', JSON.stringify(acumulado));
  }
});

Dos: no uses unload ni beforeunload para esto. Además de no dispararse de forma fiable en móvil, impiden que la página entre en la caché de retroceso, lo cual convierte una navegación instantánea hacia atrás en una recarga completa. Es una de las regresiones de rendimiento más caras y más silenciosas que existen, y se comete al instrumentar.

Tres: comprueba el estado antes de programar trabajo largo de fondo. Si la pestaña ya está oculta, no tiene sentido empezar un indexado que se va a congelar a mitad:

function programarFondo(fn) {
  if (document.visibilityState === 'hidden') return;   // no empieces lo que no vas a terminar
  return programar(fn, { priority: 'background' });
}

Y una consecuencia de diseño que va más allá de la telemetría: cualquier trabajo troceado que dure más que la atención del usuario tiene que ser reanudable. Guarda el progreso en almacenamiento persistente cada cierto número de lotes, y al volver a la página, reanuda donde estabas. Un indexado de veinte mil registros que empieza de cero cada vez que el usuario cambia de pestaña no termina nunca en un móvil, y ese fallo solo se reproduce en las condiciones reales de uso, nunca en la mesa de desarrollo.

La relación con la cesión

postTask y yield resuelven problemas distintos y se combinan bien.

postTask programa trabajo nuevo con una prioridad. Lo usas cuando decides que algo se haga más tarde.

yield parte trabajo que ya está corriendo. Lo usas dentro de un bucle largo para no monopolizar el hilo.

La combinación natural es programar el trabajo con la prioridad adecuada y, dentro de él, ceder periódicamente:

scheduler.postTask(async () => {
  for (const lote of enLotes(registros, 500)) {
    procesarLote(lote);
    await ceder();          // la continuacion hereda la prioridad de esta tarea
  }
}, { priority: 'user-visible' });

La herencia de prioridad de la cesión es lo que hace que esto funcione bien: los trozos siguientes mantienen la prioridad con la que empezó la tarea, en lugar de degradarse a la cola normal.

⚔️ Reto práctico

Sustituye en tu aplicación el patrón de limitación de frecuencia de una búsqueda incremental por postTask con cancelación. Mide, con la CPU estrangulada, el tiempo desde la última pulsación hasta que aparecen los resultados, y cuántas ejecuciones de la función de búsqueda ocurren al escribir una palabra de ocho letras. Compara con la implementación anterior: deberías ver menos ejecuciones y menor latencia a la vez.