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ó drásticamente, desde documentos estáticos con algunos estilos e imágenes hasta aplicaciones dinámicas y complejas. Sin embargo, una cosa se mantuvo casi sin cambios: tenemos solo un subproceso por pestaña del navegador (con algunas excepciones) para renderizar nuestros sitios y ejecutar nuestro JavaScript.

Como resultado, el subproceso principal se sobrecargó de manera increíble. A medida que las apps web se vuelven más complejas, el subproceso principal se convierte en un cuello de botella importante para el rendimiento. Para empeorar las cosas, el tiempo que lleva ejecutar el código en el subproceso principal para un usuario determinado es casi completamente impredecible, ya que las capacidades del dispositivo tienen un efecto enorme 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 básicos con restricciones extremas hasta máquinas insignia de alta potencia y alta frecuencia de actualización.

Si queremos que las apps 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é usar Web Workers?

De forma predeterminada, JavaScript es un lenguaje de subproceso único que ejecuta tareas en el subproceso principal. Sin embargo, los trabajadores web proporcionan una especie de vía de escape del subproceso principal, ya que permiten a los desarrolladores crear subprocesos separados para controlar el trabajo fuera del subproceso principal. Si bien el alcance de los trabajadores web es limitado y no ofrecen acceso directo al DOM, pueden ser muy beneficiosos si hay una cantidad considerable de trabajo que debe realizarse y que, de lo contrario, sobrecargaría el subproceso principal.

En lo que respecta a las Métricas web esenciales, ejecutar el trabajo fuera del subproceso principal puede ser beneficioso. En particular, descargar el 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 en el subproceso principal, especialmente durante el inicio, también conlleva un beneficio potencial para el 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 general del subproceso principal, puedes asegurarte de que sea menos probable que el elemento de LCP de tu página se bloquee por un trabajo costoso que un trabajador web podría controlar.

Subprocesos con trabajadores web

Otras plataformas suelen admitir el trabajo paralelo, ya que te permiten asignar una función a un subproceso, que se ejecuta en paralelo con el resto del programa. Puedes acceder a las mismas variables desde ambos subprocesos, y el acceso a estos recursos compartidos se puede sincronizar con mutex y semáforos para evitar condiciones de carrera.

En JavaScript, podemos obtener una funcionalidad similar con 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 del trabajador, que comienza a ejecutar ese archivo en un subproceso independiente:

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

Comunícate con el trabajador web enviando mensajes a través de la API de postMessage. Pasa el valor del mensaje como un parámetro en la llamada a postMessage y, luego, agrega un objeto de escucha de eventos de 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 subproceso de trabajo 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);
});

Sin duda, este enfoque es algo limitado. Históricamente, los trabajadores web se usaron principalmente para quitar una sola tarea pesada del subproceso principal. Intentar controlar varias operaciones con un solo trabajador web se vuelve engorroso rápidamente: debes codificar no solo los parámetros, sino también la operación en el mensaje, y debes llevar un registro para hacer coincidir las respuestas con las solicitudes. Es probable que esa complejidad sea la razón por la que los trabajadores web no se adoptaron de forma más generalizada.

Sin embargo, si pudiéramos eliminar 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 precisamente eso.

Comlink es una biblioteca cuyo objetivo es permitirte usar Web Workers sin tener que pensar en los detalles de postMessage. Comlink te permite compartir variables entre los subprocesos de trabajo 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 para exponer al subproceso principal. Luego, importa Comlink en el subproceso principal, encapsula el trabajador y obtén 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 subproceso de trabajo web, excepto que cada función devuelve una promesa para 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 en un trabajador partes de tu app que dependan de ese acceso. Aun así, cada pequeño fragmento de código que se mueve a un worker permite más espacio en el subproceso principal para elementos que deben estar allí, como la actualización de 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 coordinar todo en la app. Todo es un componente del framework y, por lo tanto, está inherentemente vinculado al DOM. Esto dificultaría la migración a una arquitectura de OMT.

Sin embargo, si cambiamos a un modelo en el que los problemas de la IU se separan de otros problemas, como la administración del estado, los trabajadores web pueden ser muy útiles incluso con las apps basadas en frameworks. Ese es exactamente el enfoque que se adoptó con PROXX.

PROXX: Un caso de estudio de OMT

El equipo de Google Chrome desarrolló PROXX como un clon de Buscaminas 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 tuvieron un rendimiento deficiente en dispositivos con limitaciones, como los teléfonos básicos, lo que llevó al equipo a darse cuenta de que el subproceso principal era un cuello de botella.

El equipo decidió usar Web Workers 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 del teléfono básico de PROXX. En la versión que no es de OMT, la IU se bloquea 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 esto 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 fotogramas que la versión sin OMT, que no envía ningún fotograma. Por lo tanto, el usuario sabe que algo está sucediendo y puede seguir jugando a medida que se actualiza la IU, lo que hace que el juego se sienta mucho mejor.

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

Esta es una compensación consciente: les brindamos a los usuarios de dispositivos con limitaciones 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, OMT hace que tu app se ejecute de manera confiable en una mayor variedad 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 subproceso de trabajo web y el subproceso principal a veces puede ralentizar las cosas de forma marginal.

Considera las ventajas y desventajas

Dado que el subproceso principal es libre de procesar las interacciones del usuario, como el desplazamiento, mientras se ejecuta JavaScript, hay menos fotogramas descartados, aunque el tiempo de espera total puede ser ligeramente 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 distintos dispositivos, el objetivo de la arquitectura de OMT es reducir el riesgo, es decir, hacer que tu app sea más sólida ante condiciones de tiempo de ejecución muy variables, y no se trata de 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 Web Workers aún no son populares, por lo que la mayoría de las herramientas de módulos, como webpack y Rollup, no los admiten de forma predeterminada. (aunque 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, debemos admitir dispositivos con limitaciones, ya que son la forma en que la mayoría de los usuarios acceden a la Web en todo el mundo. La OMT ofrece una forma prometedora de aumentar el rendimiento en estos 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 intimidantes. Herramientas como Comlink están facilitando el trabajo de los trabajadores y convirtiéndolos en una opción viable para una amplia variedad de aplicaciones web.