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

isInputPending(): ceder solo cuando alguien está esperando

La API que permite trabajar sin interrupciones hasta que llega una entrada, por qué su soporte está limitado a un solo motor, en qué casos gana a ceder por tiempo, y por qué su propio autor recomienda preferir la cesión.

⏱ 16 min

El troceado por presupuesto de tiempo cede cada cuarenta milisegundos aunque no haya nadie esperando, y esas cesiones cuestan. isInputPending() propone lo contrario: seguir trabajando sin interrupción y ceder únicamente cuando el navegador detecta que hay una entrada del usuario pendiente de atender. Sobre el papel es la respuesta perfecta. En la práctica tiene un soporte limitado a un solo motor y un modo de fallo que hace que la recomendación oficial actual sea usarla como complemento y no como sustituto de la cesión periódica.

🎯 Al terminar esta lección sabrás
  • Explicar qué detecta exactamente isInputPending() y qué no.
  • Enunciar su soporte real y las implicaciones de esa limitación.
  • Implementar el patrón combinado de cesión por entrada y por tiempo.
  • Reconocer el modo de fallo por inanición y cómo evitarlo.

Qué detecta

navigator.scheduling.isInputPending() devuelve true si hay eventos de entrada del usuario esperando a ser procesados por el hilo principal. Es una consulta síncrona y barata: el navegador ya tiene esa información porque los eventos llegan al proceso del navegador antes de encolarse.

function trabajarHastaQueHagaFalta(items) {
  while (items.length) {
    if (navigator.scheduling.isInputPending()) break;   // alguien espera: para
    procesar(items.shift());
  }
  return items.length > 0;   // queda trabajo
}

Se puede afinar qué tipos de entrada cuentan:

// Solo entradas discretas: clic, tecla, toque. Ignora movimiento de puntero.
navigator.scheduling.isInputPending({ includeContinuous: false });   // por defecto

// Incluye tambien las continuas: mousemove, pointermove, wheel
navigator.scheduling.isInputPending({ includeContinuous: true });

Por defecto no incluye las entradas continuas, y es la elección correcta en la mayoría de los casos: si incluyeras el movimiento del puntero, cualquier desplazamiento del ratón sobre la ventana pararía tu trabajo constantemente.

Lo que no detecta, y conviene tener claro: no detecta que el navegador quiera pintar. Un bucle que solo cede cuando hay entrada pendiente puede bloquear el renderizado durante segundos sin que la API diga nada, porque nadie está pulsando. Ese es el modo de fallo principal.

El soporte

Aquí hay que ser directo: isInputPending() está disponible únicamente en navegadores basados en Chromium, desde la versión 87. Firefox y Safari no la implementan y no han señalado intención de hacerlo.

Eso significa que en la mitad o más de tu tráfico móvil la API no existe. La comprobación es obligatoria:

const hayEntradaPendiente = typeof navigator.scheduling?.isInputPending === 'function'
  ? () => navigator.scheduling.isInputPending()
  : () => false;    // sin la API, asumimos que no hay entrada y cedemos por tiempo

Devolver false como degradación es la elección correcta: el patrón cae de vuelta al troceado por tiempo, que funciona en todas partes.

El patrón combinado

La forma correcta de usarla no es sustituir la cesión por tiempo, sino añadirla como razón adicional para ceder antes. Se cede si hay entrada pendiente o si se agotó el presupuesto:

import { ceder } from './planificacion.js';

const hayEntrada = typeof navigator.scheduling?.isInputPending === 'function'
  ? (opciones) => navigator.scheduling.isInputPending(opciones)
  : () => false;

export async function recorrerReactivo(items, trabajo, {
  presupuestoMs = 50,
  senal,
} = {}) {
  let inicio = performance.now();
  let i = 0;
  for (const item of items) {
    if (senal?.aborted) return { completado: false, procesados: i };
    trabajo(item, i);
    i++;
    // Cede si alguien espera, o si llevamos demasiado tiempo sin pintar
    if (hayEntrada() || performance.now() - inicio >= presupuestoMs) {
      await ceder();
      inicio = performance.now();
    }
  }
  return { completado: true, procesados: i };
}

Fíjate en dos decisiones.

El presupuesto sube de 40 a 50 milisegundos. Con la comprobación de entrada, se puede ser algo más agresivo, porque el caso que de verdad importa —el usuario interactuando— se detecta directamente en lugar de estimarse.

La comprobación de entrada va primero en el if. Es la más barata y la que corta la evaluación cuando se cumple.

El beneficio medible de añadir la comprobación: menos cesiones cuando no hay nadie interactuando, lo que reduce el tiempo total de la operación, y cesión más rápida cuando sí lo hay, lo que reduce el retraso de entrada. En un bucle de dos segundos sobre un dispositivo ocupado, la diferencia en tiempo total puede ser del 10 al 20 por ciento.

El propio equipo que la propuso recomienda ceder, y el motivo es la inanición del renderizado

La historia de esta API es instructiva. Se propuso y se implementó en 2020, en un momento en que no existía scheduler.yield() y la única alternativa era setTimeout, cuyo coste era tan alto que evitar cesiones innecesarias tenía mucho valor. Con la llegada de la cesión con prioridad, el cálculo cambió, y la guía actual de rendimiento de Chromium recomienda ceder periódicamente en lugar de basar la estrategia solo en la detección de entrada.

El motivo es un modo de fallo concreto: la inanición del renderizado.

isInputPending() detecta entrada. No detecta que el navegador tenga fotogramas pendientes de pintar, ni animaciones que actualizar, ni transiciones en curso, ni contenido que cargar. Un bucle que trabaja hasta que alguien pulsa algo puede monopolizar el hilo durante segundos sin ceder ni una vez, y durante ese tiempo:

  • Las animaciones CSS que corren en el hilo principal se congelan.
  • Un indicador de carga deja de girar, justo cuando más falta hace.
  • El contenido que llega de la red no se pinta.
  • El desplazamiento por rueda con oyente no pasivo se atasca.

Y todo eso con la API diciendo, correctamente, que no hay entrada pendiente. La página está congelada y nadie está pulsando nada porque el usuario está mirando cómo no pasa nada.

Hay un segundo modo de fallo más sutil, y es de reparto. Si tu bucle cede solo cuando hay entrada, el trabajo del bucle compite mal con otro trabajo legítimo de la página. Un temporizador de actualización de datos, una animación, la carga diferida de imágenes: todo eso se pospone indefinidamente mientras tu bucle corre.

De ahí la recomendación práctica, que es la del código de arriba: el presupuesto de tiempo es el mecanismo principal y la detección de entrada es un refinamiento. Nunca al revés. Si tuvieras que elegir uno solo, elige el presupuesto de tiempo: funciona en todos los navegadores, protege el renderizado, y su coste es asumible.

Y un apunte sobre cuándo la detección de entrada sí aporta valor claro, porque no es siempre. Aporta en bucles largos y no interactivos que corren en segundo plano de la experiencia: un indexado, una migración de datos locales, un precálculo. Ahí no hay animación que proteger, el usuario no está esperando el resultado, y evitar cesiones innecesarias acorta la operación. En trabajo que produce contenido visible, la cesión periódica es lo que hace que el usuario vea el progreso, y quitarla empeora la percepción aunque mejore el reloj.

La regla de decisión

Tres frases.

Usa siempre presupuesto de tiempo. Es el mecanismo que funciona en todas partes y el que protege el renderizado.

Añade la detección de entrada donde exista, como razón adicional para ceder antes. Coste cero donde no existe, mejora medible donde sí.

No construyas la estrategia sobre la detección de entrada. Ni el soporte lo permite ni el comportamiento lo aconseja.

⚔️ Reto práctico

Implementa recorrerReactivo y aplícala a un bucle de al menos dos segundos de trabajo en tu aplicación. Mide en Chromium tres cifras con y sin la comprobación de entrada: tiempo total de la operación, número de cesiones, y retraso de entrada de un clic disparado a mitad del bucle. Después repite la medición en Firefox o Safari, donde la comprobación siempre devuelve false, y comprueba que el comportamiento sigue siendo correcto aunque el tiempo total sea algo mayor.