Usa Web workers para ejecutar JavaScript desde el subproceso principal del navegador.

Una arquitectura fuera del subproceso principal puede mejorar significativamente la confiabilidad y la experiencia del usuario de tu app.

En los últimos 20 años, la Web evolucionó de forma significativa de documentos estáticos con algunos estilos y algunas imágenes a aplicaciones complejas y dinámicas. Sin embargo, hay una cosa que permaneció en gran medida sin cambios: solo tenemos un subproceso por pestaña del navegador (con algunas excepciones) que hace el trabajo de renderizar nuestros sitios y ejecutar nuestro JavaScript.

Como resultado, el subproceso principal se sobrecarga. A medida que las apps web aumentan de complejidad, el subproceso principal se convierte en un cuello de botella significativo para el rendimiento. Para empeorar la situación, el tiempo que lleva ejecutar código en el subproceso principal para un usuario determinado es casi completamente impredecible porque las capacidades del dispositivo tienen un efecto masivo en el rendimiento. Esa imprevisibilidad solo aumentará a medida que los usuarios accedan a la Web desde un conjunto cada vez más diverso de dispositivos, desde teléfonos con funciones hiperlimitadas hasta máquinas insignia de alta potencia y alta frecuencia de actualización.

Si queremos que las apps web sofisticadas cumplan de forma confiable con los lineamientos de rendimiento, como las Métricas web esenciales (que se basan en datos empíricos sobre la percepción y la psicología humanas), necesitamos formas de ejecutar nuestro código fuera del subproceso principal (OMT).

¿Por qué usar trabajadores web?

De forma predeterminada, JavaScript es un lenguaje de subproceso único que ejecuta tareas en el subproceso principal. Sin embargo, los Web Workers proporcionan una especie de salida de emergencia del subproceso principal, ya que permiten a los desarrolladores crear subprocesos independientes para controlar el trabajo fuera del subproceso principal. Si bien el alcance de los trabajadores web es limitado y no ofrece acceso directo al DOM, pueden ser muy beneficiosos si se debe realizar un trabajo considerable que, de lo contrario, sobrecargaría el subproceso principal.

En el caso de las Métricas web esenciales, puede ser beneficioso ejecutar el trabajo fuera del subproceso principal. En particular, descargar trabajo del subproceso principal a los trabajadores web puede reducir la contención del subproceso principal, lo que puede mejorar la métrica de capacidad de respuesta de Interaction to Next Paint (INP) de una página. Cuando el subproceso principal tiene menos trabajo para procesar, puede responder más rápido a las interacciones del usuario.

Menos trabajo del subproceso principal, especialmente durante el inicio, también puede beneficiar al procesamiento de imagen con contenido más grande (LCP), ya que reduce las tareas largas. La renderización de un elemento de LCP requiere tiempo del subproceso principal, ya sea para renderizar texto o imágenes, que son elementos de LCP frecuentes y comunes. Si reduces el trabajo del subproceso principal en general, puedes asegurarte de que sea menos probable que el elemento de LCP de tu página esté bloqueado por un trabajo costoso que un trabajador web podría controlar.

Cómo crear subprocesos con trabajadores web

Por lo general, otras plataformas admiten el trabajo paralelo, ya que te permite asignar una función a un subproceso, que se ejecuta en paralelo con el resto de tu programa. Puedes acceder a las mismas variables desde ambos subprocesos, y el acceso a estos recursos compartidos se puede sincronizar con mutexes y semáforos para evitar condiciones de carrera.

En JavaScript, podemos obtener una funcionalidad similar a la de los trabajadores web, que existen desde 2007 y son compatibles con todos los navegadores principales desde 2012. Los Web Workers se ejecutan en paralelo con el subproceso principal, pero, a diferencia de los subprocesos del SO, no pueden compartir variables.

Para crear un trabajador web, pasa un archivo al constructor de trabajadores, que comenzará a ejecutarlo en un subproceso independiente:

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

Para comunicarte con el trabajador web, envía mensajes con la API de postMessage. Pasa el valor del mensaje como parámetro en la llamada a postMessage y, luego, agrega un objeto de escucha de eventos del mensaje al trabajador:

main.js

const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  // ...
});

Para volver a enviar un mensaje al subproceso principal, usa la misma API de postMessage en el trabajador web y configura un objeto de escucha de eventos en el subproceso principal:

main.js

const worker = new Worker('./worker.js');

worker.postMessage([40, 2]);
worker.addEventListener('message', event => {
  console.log(event.data);
});

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  postMessage(a + b);
});

Es cierto que este enfoque es algo limitado. Históricamente, los trabajadores web se usaban principalmente para quitar una sola pieza de trabajo pesado del subproceso principal. Tratar de controlar varias operaciones con un solo trabajador web se vuelve inmanejable rápidamente: debes codificar no solo los parámetros, sino también la operación en el mensaje, y debes realizar un registro para hacer coincidir las respuestas con las solicitudes. Esa complejidad probablemente sea la razón por la que los trabajadores web no se han adoptado de forma más amplia.

Sin embargo, si pudiéramos quitar parte de la dificultad de la comunicación entre el subproceso principal y los trabajadores web, este modelo podría ser una excelente opción para muchos casos de uso. Y, por suerte, hay una biblioteca que hace exactamente eso.

Comlink es una biblioteca cuyo objetivo es permitirte usar trabajadores web sin tener que pensar en los detalles de postMessage. Comlink te permite compartir variables entre los trabajadores web y el subproceso principal casi como otros lenguajes de programación que admiten subprocesos.

Para configurar Comlink, instálalo en un trabajador web y define un conjunto de funciones para exponer al subproceso principal. Luego, importas Comlink en el subproceso principal, unes el trabajador y obtienes acceso a las funciones expuestas:

worker.js

import {expose} from 'comlink';

const api = {
  someMethod() {
    // ...
  }
}

expose(api);

main.js

import {wrap} from 'comlink';

const worker = new Worker('./worker.js');
const api = wrap(worker);

La variable api en el subproceso principal se comporta de la misma manera que la del trabajador web, excepto que cada función muestra una promesa de un valor en lugar del valor en sí.

¿Qué código deberías transferir a un trabajador web?

Los trabajadores web no tienen acceso al DOM ni a muchas APIs, como WebUSB, WebRTC o Web Audio, por lo que no puedes colocar partes de tu app que dependan de ese acceso en un trabajador. Sin embargo, cada pequeño fragmento de código que se mueve a un trabajador compra más margen en el subproceso principal para los elementos que tienen que estar allí, como actualizar la interfaz de usuario.

Un problema para los desarrolladores web es que la mayoría de las apps web dependen de un framework de IU como Vue o React para organizar todo en la app. Todo es un componente del framework y, por lo tanto, está inherentemente vinculado al DOM. Eso parece dificultar la migración a una arquitectura de OMT.

Sin embargo, si pasamos a un modelo en el que las inquietudes de la IU están separadas de otras, como la administración del estado, los trabajadores web pueden ser bastante útiles incluso con apps basadas en frameworks. Ese es exactamente el enfoque que se adoptó con PROXX.

PROXX: un caso de éxito de OMT

El equipo de Google Chrome desarrolló PROXX como un clon de Minesweeper que cumple con los requisitos de las aplicaciones web progresivas, como funcionar sin conexión y tener una experiencia del usuario atractiva. Lamentablemente, las primeras versiones del juego tenían un rendimiento bajo en dispositivos con limitaciones, como los teléfonos con funciones, lo que llevó al equipo a darse cuenta de que el subproceso principal era un cuello de botella.

El equipo decidió usar trabajadores web para separar el estado visual del juego de su lógica:

  • El subproceso principal controla la renderización de animaciones y transiciones.
  • Un trabajador web controla la lógica del juego, que es puramente computacional.

La OMT tuvo efectos interesantes en el rendimiento de los teléfonos celulares de PROXX. En la versión sin OMT, la IU se inmoviliza durante seis segundos después de que el usuario interactúa con ella. No hay comentarios, y el usuario debe esperar los seis segundos completos antes de poder hacer otra cosa.

Tiempo de respuesta de la IU en la versión no OMT de PROXX.

Sin embargo, en la versión de OMT, el juego tarda doce segundos en completar una actualización de la IU. Si bien eso parece una pérdida de rendimiento, en realidad genera más comentarios para el usuario. La ralentización se produce porque la app envía más tramas que la versión sin OMT, que no envía ninguna. Por lo tanto, el usuario sabe que está sucediendo algo y puede seguir jugando mientras se actualiza la IU, lo que hace que el juego se sienta mucho mejor.

Tiempo de respuesta de la IU en la versión OMT de PROXX.

Esta es una compensación consciente: brindamos a los usuarios de dispositivos limitados una experiencia que se siente mejor sin penalizar a los usuarios de dispositivos de alta gama.

Implicaciones de una arquitectura de OMT

Como se muestra en el ejemplo de PROXX, la OMT permite que tu app se ejecute de forma confiable en una variedad más amplia de dispositivos, pero no la hace más rápida:

  • Solo estás moviendo el trabajo del subproceso principal, no lo estás reduciendo.
  • La sobrecarga de comunicación adicional entre el trabajador web y el subproceso principal a veces puede ralentizar las cosas un poco.

Considera las compensaciones

Dado que el subproceso principal puede procesar interacciones del usuario, como el desplazamiento, mientras se ejecuta JavaScript, hay menos fotogramas perdidos, aunque el tiempo de espera total puede ser un poco más largo. Es preferible que el usuario espere un poco a que se pierda un fotograma, ya que el margen de error es menor para los fotogramas perdidos: la pérdida de un fotograma ocurre en milisegundos, mientras que tienes cientos de milisegundos antes de que un usuario perciba el tiempo de espera.

Debido a la imprevisibilidad del rendimiento en los dispositivos, el objetivo de la arquitectura de OMT es reducir el riesgo (hacer que tu app sea más sólida frente a condiciones de tiempo de ejecución muy variables), no los beneficios de rendimiento de la paralelización. El aumento de la resiliencia y las mejoras en la UX valen más que cualquier pequeña compensación en la velocidad.

Nota sobre las herramientas

Los trabajadores web aún no son los más comunes, por lo que la mayoría de las herramientas de módulos, como webpack y Rollup, no los admiten desde el primer momento. (Parcel sí lo hace). Por suerte, existen complementos para que los trabajadores web funcionen con Webpack y Rollup:

En resumen

Para asegurarnos de que nuestras apps sean lo más confiables y accesibles posible, especialmente en un mercado cada vez más globalizado, necesitamos admitir dispositivos con limitaciones, ya que la mayoría de los usuarios acceden a la Web a nivel mundial. OMT ofrece una forma prometedora de aumentar el rendimiento en esos dispositivos sin afectar negativamente a los usuarios de dispositivos de alta gama.

Además, OMT tiene beneficios secundarios:

Los Web Workers no tienen por qué ser abrumadores. Algunas herramientas, como Comlink, les están quitando el trabajo a los trabajadores y las convierten en una opción viable para una amplia variedad de aplicaciones web.