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

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

En los últimos 20 años, la Web ha evolucionado drásticamente de documentos estáticos con pocos estilos e 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 hilo principal se volvió increíblemente sobrecargado. A medida que aumenta la complejidad de las apps web, el subproceso principal se convierte en un gran cuello de botella 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 de dispositivos cada vez más diverso, desde teléfonos de gama media altamente limitados hasta máquinas insignia de alta potencia y frecuencia de actualización.

Si queremos que las aplicaciones web sofisticadas cumplan de manera 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é elegir trabajadores web?

De forma predeterminada, JavaScript es un lenguaje de un solo subproceso que ejecuta tareas en el subproceso principal. Sin embargo, los trabajadores web proporcionan una especie de salida del subproceso principal, ya que permiten a los desarrolladores crear subprocesos separados para manejar 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 una tarea considerable que, de lo contrario, sobrecargaría el subproceso principal.

En lo que respecta a las Métricas web esenciales, puede ser beneficioso ejecutar el trabajo desde el subproceso principal. En particular, descargar el trabajo del subproceso principal a los trabajadores web puede reducir la contención en este último, 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 que procesar, puede responder más rápido a las interacciones del usuario.

Un menor trabajo de subprocesos principal, especialmente durante el inicio, también conlleva un posible beneficio para el procesamiento de imagen con contenido más grande (LCP), ya que se reducen las tareas largas. La renderización de un elemento LCP requiere tiempo del subproceso principal, ya sea para renderizar texto o imágenes, que son elementos LCP frecuentes y comunes. Además, si reduces el trabajo del subproceso principal en general, puedes garantizar que es menos probable que el elemento LCP de tu página esté bloqueado por tareas costosas que un trabajador web podría realizar en su lugar.

Cómo ejecutar 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 exclusiones mutuas y semáforas para evitar condiciones de carrera.

En JavaScript, podemos obtener una funcionalidad más o menos similar de los trabajadores web, que existen desde 2007 y son compatibles con todos los navegadores principales desde 2012. Los trabajadores web 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 del trabajador, que comenzará a ejecutar ese archivo en un subproceso separado:

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

Usa la API de postMessage para comunicarte con el trabajador web. 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 enviar un mensaje de vuelta 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 un poco limitado. Históricamente, los trabajadores web se usaban principalmente para quitar una sola pieza de trabajo pesado del subproceso principal. Intentar manejar varias operaciones con un solo trabajador web se vuelve difícil de manejar rápidamente: no solo debes codificar los parámetros, sino también la operación en el mensaje, y debes llevar la contabilidad 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 gran opción para muchos casos de uso. Por suerte, hay una biblioteca que hace precisamente eso.

Comlink es una biblioteca que tiene como objetivo 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, debes importarlo en un trabajador web y definir un conjunto de funciones que se expondrá al subproceso principal. Luego, importarás Comlink en el subproceso principal, unirás el trabajador y obtendrás 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 del subproceso principal se comporta igual que la del trabajador web, salvo que cada función muestra una promesa para un valor en lugar del valor en sí.

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

Los trabajadores web no tienen acceso al DOM y 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 traslada a un trabajador compra más margen en el subproceso principal para cuestiones que tiene que estar allí, como actualizar la interfaz de usuario.

Un problema para los desarrolladores web es que la mayoría de las aplicaciones 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 parecería dificultar la migración a una arquitectura 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 adoptado con PROXX.

PROXX: un caso de éxito de OMT

El equipo de Google Chrome desarrolló PROXX como un clon de Buscaminas que cumple con los requisitos de las apps web progresivas, lo que incluye trabajar sin conexión y tener una experiencia del usuario atractiva. Lamentablemente, las primeras versiones del juego tuvieron un rendimiento deficiente en dispositivos restringidos, como teléfonos de gama media, 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 maneja la lógica del juego, que es puramente computacional.

OMT tuvo efectos interesantes en el rendimiento del teléfono de gama media de PROXX. En la versión que no es OMT, la IU se bloquea durante seis segundos después de que el usuario interactúa con ella. No hay retroalimentación y el usuario tiene que esperar 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 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 los usuarios. La demora se produce porque la app envía más fotogramas que la versión que no es de OMS, que no envía ningún fotograma. Por lo tanto, el usuario sabe que algo está sucediendo y puede continuar jugando a medida que se actualiza la IU, lo que hace que el juego se sienta considerablemente 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 OMT

Como se muestra en el ejemplo de PROXX, OMT hace que tu app se ejecute de manera confiable en una gama más amplia de dispositivos, pero no hace que sea más rápida:

  • Solo moverás el trabajo del subproceso principal, no lo reducirás.
  • La sobrecarga de comunicación adicional entre el trabajador web y el hilo principal a veces puede hacer que las cosas sean marginalmente más lentas.

Considera las ventajas y desventajas

Debido a que el subproceso principal es libre de procesar las interacciones del usuario, como el desplazamiento mientras se ejecuta JavaScript, hay menos fotogramas perdidos, aunque el tiempo de espera total puede ser marginalmente mayor. Es preferible hacer que el usuario espere un poco antes que descartar un fotograma, ya que el margen de error es menor en el caso de los fotogramas descartados: la pérdida de un fotograma se produce en milisegundos, mientras que tienes cientos de milisegundos antes de que el usuario perciba el tiempo de espera.

Debido a la imprevisibilidad del rendimiento en todos los dispositivos, el objetivo de la arquitectura OMT se centra en reducir el riesgo (hacer que tu app sea más sólida ante condiciones de tiempo de ejecución muy variables) y no en 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ño sacrificio 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). Afortunadamente, 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 global. OMT ofrece una manera 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:

  • Traslada los costos de ejecución de JavaScript a un subproceso independiente.
  • Aumenta los costos del análisis, lo que significa que la IU puede iniciarse más rápido. Esto podría reducir el First Contentful Paint o incluso Tiempo para ser interactivo, lo que, a su vez, puede aumentar Lighthouse.

Los trabajadores web no tienen por qué preocuparse. Las herramientas como Comlink les están quitando el trabajo a los trabajadores y los convierten en una opción viable para una amplia gama de aplicaciones web.

Hero image de Unsplash, de James Peacock.