Te dijeron que no bloquees el subproceso principal y que dividas las tareas largas, pero ¿qué significa hacer esas cosas?
Fecha de publicación: 30 de septiembre de 2022; última actualización: 19 de diciembre de 2024
Los consejos habituales para mantener la velocidad de las apps de JavaScript suelen resumirse en los siguientes:
- "No bloquees el subproceso principal".
- "Divide las tareas largas".
Este es un gran consejo, pero ¿qué trabajo implica? Enviar menos JavaScript es bueno, pero ¿eso equivale automáticamente a interfaces de usuario más responsivas? Tal vez, pero tal vez no.
Para comprender cómo optimizar las tareas en JavaScript, primero debes saber qué son las tareas y cómo las controla el navegador.
¿Qué es una tarea?
Una tarea es cualquier parte discreta del trabajo que realiza el navegador. Ese trabajo incluye la renderización, el análisis de HTML y CSS, la ejecución de JavaScript y otros tipos de trabajo sobre los que es posible que no tengas control directo. De todo esto, el código JavaScript que escribes es quizás la mayor fuente de tareas.

click
en el generador de perfiles de rendimiento de las Herramientas para desarrolladores de Chrome.
Las tareas asociadas con JavaScript afectan el rendimiento de varias maneras:
- Cuando un navegador descarga un archivo JavaScript durante el inicio, pone en cola las tareas para analizar y compilar ese JavaScript de modo que se pueda ejecutar más tarde.
- En otros momentos durante la vida útil de la página, las tareas se ponen en cola cuando JavaScript realiza trabajos como responder a interacciones a través de controladores de eventos, animaciones controladas por JavaScript y actividad en segundo plano, como la recopilación de estadísticas.
Todo esto, a excepción de los trabajadores web y las APIs similares, sucede en el subproceso principal.
¿Qué es el subproceso principal?
El subproceso principal es donde se ejecutan la mayoría de las tareas en el navegador y donde se ejecuta casi todo el código JavaScript que escribes.
El subproceso principal solo puede procesar una tarea a la vez. Cualquier tarea que lleve más de 50 milisegundos es una tarea larga. En el caso de las tareas que superan los 50 milisegundos, el tiempo total de la tarea menos 50 milisegundos se conoce como el período de bloqueo de la tarea.
El navegador bloquea las interacciones mientras se ejecuta una tarea de cualquier duración, pero esto no es perceptible para el usuario siempre que las tareas no se ejecuten durante demasiado tiempo. Sin embargo, cuando un usuario intenta interactuar con una página cuando hay muchas tareas largas, la interfaz de usuario se sentirá lenta y, posiblemente, incluso dañada si el subproceso principal se bloquea durante períodos muy largos.

Para evitar que el subproceso principal se bloquee durante demasiado tiempo, puedes dividir una tarea larga en varias más pequeñas.

Esto es importante porque, cuando las tareas se dividen, el navegador puede responder al trabajo de mayor prioridad mucho antes, incluidas las interacciones del usuario. Luego, las tareas restantes se ejecutan hasta completarse, lo que garantiza que se realice el trabajo que pusiste en la cola inicialmente.

En la parte superior de la figura anterior, un controlador de eventos en cola por una interacción del usuario tuvo que esperar una sola tarea larga antes de poder comenzar, lo que retrasa la interacción. En esta situación, es posible que el usuario haya notado un retraso. En la parte inferior, el controlador de eventos puede comenzar a ejecutarse antes, y la interacción podría haber parecido instantánea.
Ahora que sabes por qué es importante dividir las tareas, puedes aprender a hacerlo en JavaScript.
Estrategias de administración de tareas
Un consejo común en la arquitectura de software es dividir tu trabajo en funciones más pequeñas:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
En este ejemplo, hay una función llamada saveSettings()
que llama a cinco funciones para validar un formulario, mostrar un spinner, enviar datos al backend de la aplicación, actualizar la interfaz de usuario y enviar estadísticas.
Conceptualmente, saveSettings()
está bien diseñada. Si necesitas depurar una de estas funciones, puedes recorrer el árbol del proyecto para averiguar qué hace cada función. Dividir el trabajo de esta manera facilita la navegación y el mantenimiento de los proyectos.
Sin embargo, un posible problema aquí es que JavaScript no ejecuta cada una de estas funciones como tareas separadas porque se ejecutan dentro de la función saveSettings()
. Esto significa que las cinco funciones se ejecutarán como una sola tarea.

saveSettings()
que llama a cinco funciones. El trabajo se ejecuta como parte de una tarea monolítica larga, lo que bloquea cualquier respuesta visual hasta que se completan las cinco funciones.
En el mejor de los casos, incluso una sola de esas funciones puede contribuir con 50 milisegundos o más a la duración total de la tarea. En el peor de los casos, más de esas tareas pueden ejecutarse durante mucho más tiempo, en especial en dispositivos con recursos limitados.
En este caso, saveSettings()
se activa con un clic del usuario y, como el navegador no puede mostrar una respuesta hasta que finaliza la ejecución de toda la función, el resultado de esta tarea larga es una IU lenta y que no responde, y se medirá como un Interaction to Next Paint (INP) deficiente.
Cómo aplazar manualmente la ejecución del código
Para asegurarte de que las tareas importantes orientadas al usuario y las respuestas de la IU se realicen antes que las tareas de menor prioridad, puedes ceder el control al subproceso principal interrumpiendo brevemente tu trabajo para darle al navegador la oportunidad de ejecutar tareas más importantes.
Un método que los desarrolladores han usado para dividir las tareas en otras más pequeñas implica setTimeout()
. Con esta técnica, pasas la función a setTimeout()
. Esto pospone la ejecución de la devolución de llamada en una tarea separada, incluso si especificas un tiempo de espera de 0
.
function saveSettings () {
// Do critical work that is user-visible:
validateForm();
showSpinner();
updateUI();
// Defer work that isn't user-visible to a separate task:
setTimeout(() => {
saveToDatabase();
sendAnalytics();
}, 0);
}
Esto se conoce como cesión y funciona mejor para una serie de funciones que deben ejecutarse de forma secuencial.
Sin embargo, es posible que tu código no siempre esté organizado de esta manera. Por ejemplo, podrías tener una gran cantidad de datos que deben procesarse en un bucle, y esa tarea podría llevar mucho tiempo si hay muchas iteraciones.
function processData () {
for (const item of largeDataArray) {
// Process the individual item here.
}
}
Usar setTimeout()
aquí es problemático debido a la ergonomía del desarrollador, y después de cinco rondas de setTimeout()
anidados, el navegador comenzará a imponer una demora mínima de 5 milisegundos para cada setTimeout()
adicional.
setTimeout
también tiene otro inconveniente cuando se trata de ceder: cuando cedes al subproceso principal posponiendo la ejecución del código en una tarea posterior con setTimeout
, esa tarea se agrega al final de la cola. Si hay otras tareas en espera, se ejecutarán antes que tu código diferido.
Una API de rendimiento dedicada: scheduler.yield()
scheduler.yield()
es una API diseñada específicamente para ceder el control al subproceso principal en el navegador.
No es una sintaxis a nivel del lenguaje ni una construcción especial; scheduler.yield()
es solo una función que devuelve un Promise
que se resolverá en una tarea futura. Cualquier código encadenado para ejecutarse después de que se resuelva ese Promise
(ya sea en una cadena .then()
explícita o después de await
en una función asíncrona) se ejecutará en esa tarea futura.
En la práctica, inserta un await scheduler.yield()
y la función pausará la ejecución en ese punto y cederá el control al subproceso principal. La ejecución del resto de la función, denominada continuación de la función, se programará para que se ejecute en una nueva tarea de bucle de eventos. Cuando comience esa tarea, se resolverá la promesa esperada y la función continuará ejecutándose desde donde la dejó.
async function saveSettings () {
// Do critical work that is user-visible:
validateForm();
showSpinner();
updateUI();
// Yield to the main thread:
await scheduler.yield()
// Work that isn't user-visible, continued in a separate task:
saveToDatabase();
sendAnalytics();
}

saveSettings()
ahora se divide en dos tareas. Como resultado, el diseño y la pintura pueden ejecutarse entre las tareas, lo que le brinda al usuario una respuesta visual más rápida, medida por la interacción del puntero, que ahora es mucho más corta.
Sin embargo, el beneficio real de scheduler.yield()
sobre otros enfoques de rendimiento es que se prioriza su continuación, lo que significa que, si cedes en medio de una tarea, la continuación de la tarea actual se ejecutará antes de que se inicien otras tareas similares.
Esto evita que el código de otras fuentes de tareas interrumpa el orden de ejecución de tu código, como las tareas de secuencias de comandos de terceros.

scheduler.yield()
, la continuación se reanuda desde donde se interrumpió antes de pasar a otras tareas.
Compatibilidad entre navegadores
scheduler.yield()
aún no es compatible con todos los navegadores, por lo que se necesita una alternativa.
Una solución es agregar el scheduler-polyfill
a tu compilación, y luego se puede usar scheduler.yield()
directamente. El polyfill se encargará de recurrir a otras funciones de programación de tareas para que funcione de manera similar en todos los navegadores.
Como alternativa, se puede escribir una versión menos sofisticada en pocas líneas, usando solo setTimeout
envuelto en una promesa como alternativa si scheduler.yield()
no está disponible.
function yieldToMain () {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
// Fall back to yielding with setTimeout.
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
Si bien los navegadores sin compatibilidad con scheduler.yield()
no obtendrán la continuación priorizada, seguirán cediendo para que el navegador siga respondiendo.
Por último, puede haber casos en los que tu código no pueda ceder el control al subproceso principal si no se prioriza su continuación (por ejemplo, una página que se sabe que está ocupada en la que ceder el control podría impedir que se complete el trabajo durante un tiempo). En ese caso, scheduler.yield()
podría tratarse como una especie de mejora progresiva: producir en los navegadores en los que scheduler.yield()
está disponible y, de lo contrario, continuar.
Esto se puede hacer detectando funciones y recurriendo a esperar una sola microtarea en una práctica línea de código:
// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();
Divide el trabajo de larga duración con scheduler.yield()
El beneficio de usar cualquiera de estos métodos de scheduler.yield()
es que puedes await
en cualquier función de async
.
Por ejemplo, si tienes un array de trabajos para ejecutar que a menudo terminan sumando una tarea larga, puedes insertar rendimientos para dividir la tarea.
async function runJobs(jobQueue) {
for (const job of jobQueue) {
// Run the job:
job();
// Yield to the main thread:
await yieldToMain();
}
}
Se priorizará la continuación de runJobs()
, pero se permitirá que se ejecute el trabajo de mayor prioridad, como responder visualmente a la entrada del usuario, sin tener que esperar a que finalice la lista de trabajos, que podría ser larga.
Sin embargo, este no es un uso eficiente del rendimiento. scheduler.yield()
es rápido y eficiente, pero tiene cierta sobrecarga. Si algunos de los trabajos en jobQueue
son muy cortos, la sobrecarga podría acumularse rápidamente y generar más tiempo dedicado a ceder y reanudar que a ejecutar el trabajo real.
Un enfoque es procesar los trabajos por lotes y solo ceder entre ellos si ha pasado el tiempo suficiente desde la última cesión. Un plazo común es de 50 milisegundos para evitar que las tareas se conviertan en tareas largas, pero se puede ajustar como una compensación entre la capacidad de respuesta y el tiempo para completar la cola de trabajos.
async function runJobs(jobQueue, deadline=50) {
let lastYield = performance.now();
for (const job of jobQueue) {
// Run the job:
job();
// If it's been longer than the deadline, yield to the main thread:
if (performance.now() - lastYield > deadline) {
await yieldToMain();
lastYield = performance.now();
}
}
}
El resultado es que los trabajos se dividen para que nunca tarden demasiado en ejecutarse, pero el ejecutor solo cede el control al subproceso principal cada 50 milisegundos aproximadamente.

No uses isInputPending()
La API de isInputPending()
proporciona una forma de verificar si un usuario intentó interactuar con una página y solo genera un resultado si hay una entrada pendiente.
Esto permite que JavaScript continúe si no hay entradas pendientes, en lugar de ceder y terminar al final de la cola de tareas. Esto puede generar mejoras impresionantes en el rendimiento, como se detalla en la Intención de envío, para los sitios que, de otro modo, no cederían el control al subproceso principal.
Sin embargo, desde el lanzamiento de esa API, nuestra comprensión del rendimiento aumentó, en particular con la introducción del INP. Ya no recomendamos usar esta API y, en su lugar, recomendamos ceder independientemente de si hay entrada pendiente o no por varios motivos:
- En algunas circunstancias,
isInputPending()
puede devolverfalse
de forma incorrecta a pesar de que un usuario haya interactuado. - La entrada no es el único caso en el que las tareas deben ceder. Las animaciones y otras actualizaciones periódicas de la interfaz de usuario pueden ser igual de importantes para proporcionar una página web responsiva.
- Desde entonces, se introdujeron APIs de rendimiento más completas que abordan las inquietudes sobre el rendimiento, como
scheduler.postTask()
yscheduler.yield()
.
Conclusión
Administrar tareas es un desafío, pero hacerlo garantiza que tu página responda más rápido a las interacciones del usuario. No existe un solo consejo para administrar y priorizar tareas, sino varias técnicas diferentes. Para reiterar, estos son los aspectos principales que deberás tener en cuenta cuando administres tareas:
- Cede el control al subproceso principal para las tareas críticas orientadas al usuario.
- Usa
scheduler.yield()
(con una alternativa para varios navegadores) para ceder y obtener continuaciones priorizadas de forma ergonómica - Por último, realiza la menor cantidad de trabajo posible en tus funciones.
Para obtener más información sobre scheduler.yield()
, su tarea explícita de programación relativa scheduler.postTask()
y la priorización de tareas, consulta la documentación de la API de Prioritized Task Scheduling.
Con una o más de estas herramientas, deberías poder estructurar el trabajo en tu aplicación de modo que se prioricen las necesidades del usuario y, al mismo tiempo, garantizar que se realice el trabajo menos crítico. Esto creará una mejor experiencia del usuario, que será más responsiva y agradable de usar.
Agradecemos especialmente a Philip Walton por su revisión técnica de esta guía.
Imagen en miniatura obtenida de Unsplash, cortesía de Amirali Mirhashemian.