Inicio / Artículos

Mantener la página responsive: cuándo transferir el trabajo de la CPU a un Web Worker

Aprenda por qué los bucles intensivos congelan la interfaz de usuario del navegador, cómo el patrón de mensajes de Web Worker distribuye esa carga y cómo decidir si una tarea realmente merece utilizar un Worker.

Editor: REACTAPP.TOP12 min de lectura2703 palabras

Fuentes: JavaScript Workers: How to Run Code Without Freezing Your UI

Un usuario presiona un botón, la etiqueta cambia a “Procesando…”, y luego no hay respuesta alguna: las animaciones se detienen, los desplazamientos dejan de funcionar y los clics posteriores son ignorados. La pestaña no se ha caído; simplemente un fragmento de JavaScript está monopolizando el hilo principal. Esta guía explica por qué ocurre esto, muestra el patrón básico de Web Worker para realizar cálculos intensivos en otro lugar y le proporciona una forma práctica de decidir cuándo un Worker es la solución adecuada y cuándo solo añade complejidad.

Por qué una tarea larga congela todo

El navegador en su conjunto hace mucho más que ejecutar tus scripts, pero el JavaScript perteneciente a la página se ejecuta en un hilo principal. Si deseas comprender mejor cómo ese hilo se integra en el diseño multiproceso del navegador, consulta El hilo único de JavaScript frente al motor multiproceso del navegador. La consecuencia práctica es sencilla: ese hilo puede convertirse en un cuello de botella.

Toma una función que suma todos los números enteros menores a mil millones. No hay nada incorrecto en ella, y el motor la ejecutará sin problemas hasta su finalización:

function calculate() {
  let total = 0;
  for (let i = 0; i < 1_000_000_000; i++) {
      total += i;
    }
    return total;
  }
calculate();

El problema radica en lo que sucede mientras tanto. Mientras el bucle siga ejecutándose, el hilo principal no puede ocuparse de nada más que dependa de él. Los manejadores de clics, el procesamiento de entradas y gran parte del trabajo necesario para actualizar lo que se muestra en la pantalla esperan en fila a que ese mismo hilo termine. Esa cola de espera es exactamente lo que el usuario percibe como una interfaz congelada.

La cadena de eventos se ve así:

User clicks button
       ↓
JavaScript starts heavy calculation
       ↓
Main thread stays busy
       ↓
UI work has to wait
       ↓
Page feels frozen

Aquí no hay nada roto. Tu código mantiene ocupado al hilo durante demasiado tiempo, y el navegador actúa de manera justa al permitirle terminar. Esta es también la razón por la cual el código que parecía perfectamente funcional en el entorno de desarrollo puede convertirse en un verdadero problema de rendimiento en producción: la función no ha cambiado, pero el conjunto de datos o la cantidad de cálculos que debe procesar ha aumentado.

Proporcionar un contexto de ejecución propio para tareas intensivas

Un Web Worker es un contexto de ejecución de JavaScript separado que funciona de forma independiente del script principal de la página. En lugar de acumular todo en un único hilo:

MAIN THREAD
     │
     ├── UI
     ├── Events
     ├── JavaScript
     └── Heavy calculation  ← problem

se dividen las responsabilidades de modo que la página se encarga de la interfaz de usuario, las entradas y la lógica de la página, mientras que el Worker se ocupa de los cálculos costosos:

MAIN THREAD                  WORKER
     │                         │
     ├── UI                    ├── Heavy calculation
     ├── User input            ├── Data processing
     └── Page logic            └── Result
             │                  │
             └── messages ─────┘

La línea que conecta ambos lados en ese diagrama es el detalle clave. Un Worker no comparte las variables u objetos de tu página. Ambos lados intercambian mensajes, y esa restricción determina todo lo demás relacionado con el uso de Workers.

Un primer Worker, paso a paso

Aquí está el bucle de suma mencionado anteriormente, trasladado del hilo principal. El script de la página crea el Worker a partir de un archivo separado, le envía el límite superior y registra un manejador para recibir la respuesta:

const worker = new Worker("worker.js");

worker.postMessage(1_000_000_000);

worker.onmessage = (event) => {
  console.log("Result:", event.data);
};

El archivo Worker escucha los mensajes entrantes, ejecuta el bucle utilizando el número que se le asignó y envía de vuelta el total:

self.onmessage = (event) => {
  let total = 0;
  for (let i = 0; i < event.data; i++) {
    total += i;
  }
  self.postMessage(total);
};

Es útil rastrear el viaje de ida y vuelta llamada por llamada. La página le pasa sus datos al Worker mediante postMessage:

worker.postMessage(...)

Dentro del Worker, self se refiere al ámbito global del Worker, y su manejador onmessage es donde llegan los datos de entrada:

self.onmessage = (...)

Una vez finalizado el cálculo, el Worker responde a través de su propio postMessage:

self.postMessage(...)

Finalmente, el manejador de la página en el objeto Worker recibe esa respuesta como event.data:

worker.onmessage = (...)

Tenga en cuenta que asignar onmessage después de llamar a postMessage en el script de la página está bien: la respuesta solo puede llegar de forma asíncrona, después de que el script actual haya terminado de ejecutarse, por lo que el manejador siempre está listo a tiempo.

¿Qué cambia y qué no?

Main thread
     ↓
Heavy calculation
     ↓
UI waits

Con uno, el hilo principal transfiere la tarea y continúa sirviendo la página mientras el cálculo se realiza en paralelo:

Main thread ──→ Worker
     │             │
     │             └── Heavy calculation
     │
     └── continues handling the page

Debes tener claro qué ganas con esto. Mover el trabajo a un Worker generalmente no hace que se complete antes. El bucle tarda más o menos lo mismo que antes, y el viaje de ida y vuelta de los mensajes añade un poco más de tiempo. Lo que sí ganas es que el cálculo ya no compite por recursos temporales con el hilo principal, por lo que la página sigue siendo interactiva mientras se ejecuta.

Comunicación con un Worker: mensajes, no el DOM

El cambio mental más importante es que un Worker no puede acceder a la página. No existe un document en el ámbito global de un Worker, por lo que una llamada como esta fallará allí:

document.querySelector("#result");

Todo lo que cruza esa frontera se hace a través de mensajes. Envías los datos con:

worker.postMessage(data);

y escuchas lo que regresa con:

worker.onmessage = (event) => {
  console.log(event.data);
};

Esta restricción resulta ser una ventaja. Dado que el Worker solo puede recibir entrada y emitir salida, el flujo de datos entre ambas partes siempre es explícito:

Main Thread
     │
     │ postMessage()
     ▼
  Worker
     │
     │ postMessage()
     ▼
Main Thread

Para la mayoría de las aplicaciones, un límite estrecho en forma de mensaje entre “la parte que renderiza” y “la parte que calcula” es exactamente el diseño que se desearía en cualquier caso. El Worker nunca toca el DOM, y la página decide qué hacer con cada resultado.

Refactorización de un manejador de clic bloqueante

El punto de partida típico es un manejador de clic que procesa un gran conjunto de datos de forma síncrona y renderiza la salida de inmediato:

button.addEventListener("click", () => {
  const result = processHugeDataset(data);
  renderResult(result);
});

Si processHugeDataset() se ejecuta durante un tiempo considerable, el hilo principal queda ocupado todo ese tiempo, y la página deja de responder hasta que regresa.

La versión basada en Worker divide el manejador en dos partes. El clic solo envía los datos; la renderización tiene lugar más tarde, cuando llega el mensaje de resultado:

const worker = new Worker("worker.js");

button.addEventListener("click", () => {
  worker.postMessage(data);
});

worker.onmessage = (event) => {
  renderResult(event.data);
};

El procesamiento costoso ahora ocurre dentro del Worker, y el hilo principal puede seguir manejando la página mientras espera. Hay dos aspectos importantes que considerar con este enfoque. Primero, el Worker se crea una sola vez, fuera del manejador, por lo que los clics repetidos reutilizan el mismo en lugar de generar uno nuevo cada vez. Segundo, si un usuario hace clic varias veces rápidamente, los resultados pueden llegar después de que el usuario ya haya pasado a otra tarea; en el código real, generalmente se asigna un id a cada mensaje o se ignoran las respuestas obsoletas para que un resultado antiguo no sobrescriba uno más reciente.

Decidir qué debe ir en un Worker

Es fácil entusiasmarse y comenzar a trasladar todas las funciones a un Worker. Rara vez es una buena idea. Un Worker cobra sentido cuando la tarea es suficientemente intensiva en CPU como para competir con el hilo principal por el tiempo de procesamiento. Algunos ejemplos típicos son:

  • Procesamiento o agregación de grandes conjuntos de datos
  • Cálculos numéricos o algorítmicos complejos
  • Transformaciones masivas de JSON
  • Análisis de archivos grandes
  • Manipulación de imágenes
  • Procesamiento de audio o video
  • Compresión y otras transformaciones de datos
  • Ciertas rutinas criptográficas o matemáticas
  • Resaltado de sintaxis y otro tipo de procesamiento de texto intensivo

El patrón recurrente es el mismo: mucha computación y poca necesidad de interactuar directamente con el DOM.

Cuando un Worker es excesivo

Para algo tan trivial como multiplicar dos números, un Worker representa solo una carga adicional innecesaria:

const total = price * quantity;

Crear un Worker implica costos iniciales, cada mensaje genera gastos de comunicación, y ahora se debe gestionar otro archivo con su propio ciclo de vida. Para operaciones simples, el JavaScript tradicional en el hilo principal es la mejor opción. En lugar de preguntarse si se podría utilizar un Worker, hay que analizar si la tarea es lo suficientemente compleja como para que dejarla en el hilo principal afecte negativamente la experiencia del usuario. Esa pregunta permite descartar la mayoría de los casos en los que un Worker solo añadiría complejidad.

El efecto secundario arquitectónico: un límite claro

Los Workers no son solo una herramienta para mejorar el rendimiento. También imponen una separación de responsabilidades. El código de la interfaz de usuario puede concentrarse en convertir las entradas en estado y el estado en la representación visual:

Input
↓
State
↓
Display

Mientras el Worker se concentra en convertir los datos de entrada en datos de salida:

Input data
↓
Processing
↓
Output data

Al aislar el procesamiento costoso de esta manera, resulta más fácil probarlo y comprender su funcionamiento, ya que el Worker actúa como una función pura detrás de una interfaz de mensajes.

No obstante, existe un compromiso real. Ahora hay que gestionar el flujo asíncrono basado en mensajes, cuándo se inician y detienen los Workers, cómo surgen los fallos y cómo se transfieren los datos a través de las fronteras. Eso es un precio razonable cuando el trabajo es intensivo. Una buena ingeniería no consiste en eliminar la complejidad por todas partes, sino en colocarla donde genere beneficios.

Los Workers dedicados, compartidos y de servicio son herramientas diferentes

"Web Worker" a menudo se utiliza como si se refiriera a una única API, pero existen varias APIs relacionadas con Workers que cumplen propósitos distintos:

  • Trabajador dedicado: ejecuta JavaScript en nombre de una página o contexto específico. Este es el tipo utilizado en toda esta guía.
  • Trabajador compartido: un único trabajador al que pueden conectarse y comunicarse a través de múltiples contextos de navegación (por ejemplo, varias pestañas del mismo origen).
  • Trabajador de servicio: se sitúa entre la página y la red para habilitar funciones como la intercepción de solicitudes, el caché y el soporte sin conexión.

Para transferir tareas que consumen muchos recursos de la CPU, lo primero que hay que entender es el Trabajador dedicado. Un Trabajador de servicio no es un sustituto directo; ambos resuelven problemas diferentes. Para una comparación más detallada de los tres, consulte Trabajadores dedicados, compartidos y de servicio: elegir el hilo corrector del navegador.

Detalles importantes en la producción

El ejemplo mínimo está diseñado intencionadamente para ser pequeño. Las aplicaciones reales requieren un poco más de atención en cuanto a fallos, limpieza y tamaño de los datos.

Escuchar errores

Una excepción no capturada dentro del Worker se muestra en la página como un evento error. Asigne un manejador para que los fallos se registren en lugar de dejar que la interfaz de usuario espere silenciosamente un resultado indefinidamente:

worker.onerror = (error) => {
  console.error("Worker error:", error);
};

Terminar Workers que ya no se necesitan

Un Worker sigue ejecutándose hasta que se detiene. Cuando un Worker existe solo para una tarea puntual, desactívelo desde la página una vez que haya terminado:

worker.terminate();

Esto es especialmente importante para Workers de corta duración; un Worker de larga duración que sirve a toda la aplicación puede simplemente permanecer activo.

La transferencia de datos no es gratuita

Cada mensaje implica mover datos entre contextos. Para la mayoría de los valores de JavaScript, el navegador utiliza el algoritmo de clonación estructurada, que genera una copia en el lado receptor. En el caso de algunos datos binarios grandes, como las instancias ArrayBuffer, se pueden pasar como objetos transferibles, lo que permite transferir la propiedad al otro lado en lugar de hacer una copia; el remitente ya no podrá utilizar ese buffer transferido posteriormente. No es necesario conocer todos los detalles de esto de inmediato, pero recuerde una regla: quitar la computación del hilo principal no hace que el envío de datos a él sea gratuito. Clonar un objeto muy grande puede consumir una cantidad considerable de tiempo en el hilo principal.

Workers y React

React no modifica la forma en que el navegador programa la ejecución de JavaScript. Si un manejador de eventos de React realiza un cálculo síncrono complejo antes de actualizar el estado, dicho cálculo sigue ejecutándose en el contexto principal de la página, al igual que cualquier otro script:

function handleClick() {
  const result = expensiveCalculation();
  setResult(result);
}

Ningún framework puede hacer desaparecer los cálculos intensivos en CPU de JavaScript. Lo que sí se puede hacer es utilizar un Worker como capa de cálculo detrás de los componentes: la interfaz de usuario envía los datos, el Worker los procesa y el resultado vuelve para ser almacenado en el estado y renderizado:

React UI
   │
   │ send data
   ▼
Web Worker
   │
   │ process data
   ▼
React UI

Esto resulta especialmente útil en interfaces donde los usuarios trabajan con grandes conjuntos de datos, editores, visualizaciones u otras vistas que requieren muchos cálculos. El framework no es lo importante; lo crucial es saber qué hilo está realizando el trabajo costoso. En un componente, esto generalmente implica crear al Worker dentro de un efecto, llamar a setResult desde su manejador de mensajes y terminarlo en la limpieza del efecto, para que los componentes desmontados no dejen Workers en ejecución.

Los Workers no representan una mejora general de velocidad

El error más común es recurrir a los Workers como solución universal para mejorar el rendimiento. Si una aplicación es lenta debido a actualizaciones innecesarias, demasiadas solicitudes de red, recursos de gran tamaño, actualizaciones ineficientes de React o código complicado, un Worker no resolverá ninguno de estos problemas. Primero identifique el cuello de botella real, generalmente con el perfilador de rendimiento del navegador, y solo luego decida si un Worker es la solución adecuada.

Una breve lista de verificación ayuda:

  1. ¿Es la tarea realmente intensiva en CPU?
  2. ¿Tarda lo suficiente como para afectar la respuesta del sistema?
  3. ¿Necesita acceso directo al DOM?
  4. ¿Sus entradas y salidas pueden transmitirse como mensajes?
  5. ¿Vale la pena añadir esa complejidad adicional?

Cuando las respuestas apunten en la dirección correcta (intensiva, larga, sin acceso al DOM, adecuada para mensajes, que vale la pena), es probable que un Worker sea una opción muy adecuada.

También ayuda a separar dos problemas que son fáciles de confundir. En el primero, JavaScript realiza más trabajo del necesario, y la solución es un algoritmo mejor o menos trabajo en general. En el segundo, el trabajo es realmente costoso pero no hay razón para bloquear la interfaz; ahí es donde brilla un Worker. Un Worker no convierte código ineficiente en código eficiente; simplemente le da al código costoso un lugar diferente donde ejecutarse.

Puntos clave

No es necesario memorizar toda la API de Worker. El modelo importante cabe en una sola imagen:

MAIN THREAD
     │
     │ "Here is the work."
     ▼
  WORKER
     │
     │ "Here is the result."
     ▼
MAIN THREAD
  • El hilo principal controla la página; cualquier tarea síncrona prolongada en él retrasa el procesamiento de entradas y las actualizaciones de la pantalla.
  • Un Worker dedicado ejecuta JavaScript de forma aislada y solo se comunica mediante postMessage y onmessage; no tiene acceso al DOM.
  • La descarga externa mantiene la interfaz de usuario receptiva, pero generalmente no acelera el cálculo en sí, y transferir grandes cantidades de datos sigue teniendo un costo.
  • Gestione el evento onerror, llame a terminate() en los Workers temporales, y considere los objetos Transferable para cargas binarias grandes.
  • Realice un perfilamiento primero. Optimice el algoritmo cuando el trabajo sea ineficiente; recurra a un Worker cuando el trabajo sea necesario pero no deba realizarse en el hilo principal.
  • La próxima vez que una página se bloquee durante un cálculo complejo, pregúntese no solo cómo hacer que la función sea más rápida, sino también si realmente necesita ejecutarse en el hilo principal.

    Lecturas relacionadas