wandres.dev
PATH2D Y OFFSCREENCANVAS · Reutilizar y salir del hilo

Lo que se gana y lo que se complica al salir del hilo principal

Decidir con criterio si el dibujo debe vivir en un worker: qué mejora de verdad, qué no mejora nada, y la lista completa de lo que deja de funcionar cuando no hay DOM.

⏱ 18 min

Transferir el control de un canvas a un worker son cuatro líneas. Decidir si hay que hacerlo es una pregunta de arquitectura que se responde mal muy a menudo, porque el beneficio se cuenta en anécdotas y el coste se paga en cada función que necesita algo del documento. Lo que sigue es el balance completo: qué mejora, qué no mejora aunque lo parezca, y la lista exacta de lo que deja de existir al otro lado.

🎯 Al terminar esta lección sabrás
  • Distinguir el trabajo que un worker elimina del que solo cambia de sitio.
  • Reconocer los tres casos en los que la mejora es real y medible.
  • Enumerar lo que hay que reimplementar por no tener DOM, con su código.
  • Decidir con un criterio explícito, y saber cómo medir si acertaste.

Lo que mejora, y por qué

La ventaja del worker no es que dibuje más rápido. Dibuja exactamente igual de rápido. Lo que gana es independencia: el bucle de dibujo deja de compartir cola de tareas con todo lo demás que ocurre en la página.

Eso se traduce en tres beneficios concretos, y conviene nombrarlos por separado porque no todos aplican siempre.

El dibujo sobrevive a las tareas largas del hilo principal. Una hidratación de framework, un JSON.parse de tres megabytes, una pausa del recolector de basura o un layout forzado bloquean el hilo principal durante decenas o cientos de milisegundos. Con el canvas en el hilo principal eso es un salto visible. Con el canvas en un worker, el bucle sigue emitiendo fotogramas y el compositor los sigue mostrando, porque la composición tampoco depende del hilo principal.

La interacción de la página deja de competir con el dibujo. Es el beneficio inverso y suele importar más. Si tu canvas consume ocho milisegundos por fotograma en el hilo principal, cada clic en un botón de la interfaz espera a que ese fotograma termine. Sacarlo devuelve el presupuesto entero de interacción al resto de la aplicación, y eso se ve directamente en la métrica de latencia de interacción.

El arranque puede solaparse. El worker puede cargar sus datos y pintar el primer fotograma mientras el hilo principal todavía está ejecutando el bundle del framework. En una aplicación de visualización pesada, esto adelanta el primer dibujo varios cientos de milisegundos sin optimizar nada.

Lo que no mejora

Cuatro creencias frecuentes que no se sostienen, y que hacen perder semanas.

El trabajo total no baja. Un worker es un hilo más, no trabajo menos. En un portátil con ocho núcleos ociosos eso es gratis; en un móvil de gama media con el navegador ya usando cuatro hilos, el worker compite por el mismo silicio y puede empeorar el resultado global.

La GPU sigue siendo una sola. Si tu cuello de botella es la tasa de relleno —muchos píxeles, muchas capas translúcidas, muchas sombras— el worker no cambia nada, porque el trabajo caro nunca estuvo en el hilo de JavaScript. El síntoma que distingue un caso del otro está en el perfil: si el hilo principal aparece con huecos y la GPU saturada, mover el dibujo es inútil.

El coste de un dibujo mal escrito viaja con él. Reconstruir un Path2D por fotograma sigue siendo igual de caro dentro del worker. La diferencia es que ahora no lo ves en el sitio donde sueles mirar. Optimizar primero y mover después evita mover un problema en lugar de resolverlo.

postMessage no es gratis. Cada mensaje serializa, encola y despierta al otro hilo. Un diseño que manda cien mensajes por fotograma puede gastar en comunicación más de lo que ahorra en dibujo, y es exactamente lo que ocurre cuando se reenvía cada evento de puntero sin agrupar, según lo visto en la lección del worker.

Todo lo que deja de existir

Dentro de un worker no hay document, no hay window, no hay getComputedStyle y no hay elementos. Tres cosas que en el hilo principal son una línea pasan a ser un procedimiento.

Las fuentes hay que cargarlas explícitamente. El worker no hereda las del documento, y si no las cargas, el texto sale con la fuente por defecto sin ningún error.

// Dentro del worker
const fuente = new FontFace('Inter', 'url(/fuentes/inter-var.woff2)', { weight: '100 900' });
await fuente.load();
self.fonts.add(fuente);
ctx.font = '600 16px Inter';

Las imágenes no se cargan con un elemento. No hay Image, así que el camino es fetch más createImageBitmap, que además decodifica fuera del hilo principal por definición.

async function cargarBitmap(url) {
  const respuesta = await fetch(url);
  const blob = await respuesta.blob();
  return createImageBitmap(blob);
}

Los colores del tema hay que pasarlos. No existe getComputedStyle, con lo que las variables CSS del documento son invisibles. Hay que leerlas en el hilo principal y enviarlas, y volver a enviarlas cuando el tema cambie.

// Hilo principal
function enviarTema() {
  const estilo = getComputedStyle(document.documentElement);
  worker.postMessage({
    tipo: 'tema',
    colores: {
      fondo: estilo.getPropertyValue('--fondo').trim(),
      trazo: estilo.getPropertyValue('--trazo').trim(),
      acento: estilo.getPropertyValue('--acento').trim(),
    },
  });
}
enviarTema();
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', enviarTema);

Y a la lista se añaden cuatro asuntos menos visibles pero igual de reales: la depuración exige seleccionar el hilo del worker en el depurador y en el panel de rendimiento, porque por defecto no aparece; los errores no llegan a tu manejador global salvo que escuches worker.onerror; el empaquetado necesita que el worker sea un punto de entrada propio, lo que en algunos empaquetadores obliga a la sintaxis con new URL; y el estado se duplica, con lo que cualquier dato que ambos lados necesiten hay que sincronizarlo o decidir de quién es la verdad.

// Punto de entrada portable en los empaquetadores modernos
const worker = new Worker(new URL('./render.js', import.meta.url), { type: 'module' });
worker.onerror = e => console.error('El worker de dibujo fallo:', e.message);
El worker arregla la fluidez del canvas, no la de la página, y el usuario percibe la segunda

Hay un resultado que sorprende la primera vez y que conviene anticipar: después de mover el dibujo a un worker, el canvas va perfectamente fluido y la aplicación sigue sintiéndose lenta. La razón es que la percepción de lentitud casi nunca viene del canvas: viene del scroll que se atasca, del botón que tarda en responder, del menú que aparece tarde. Todo eso vive en el hilo principal, y sigue exactamente igual de bloqueado que antes. Has cambiado el sitio donde se ve el problema, no el problema. La consecuencia práctica es que el worker es una optimización de la página, no del canvas, y por eso el orden correcto de trabajo es al revés de como se suele hacer: primero se mide qué tarea larga bloquea el hilo principal, y solo si esa tarea es el propio dibujo tiene sentido moverlo. Hay dos síntomas que sí justifican la migración sin más análisis. Uno: la animación se congela en momentos identificables —al abrir un panel, al cargar datos, al montar un componente— y el resto de la interfaz responde bien. Ahí el canvas es la víctima y el worker lo salva. Dos: la métrica de latencia de interacción de la página es mala y el perfil muestra el dibujo del canvas dentro de las tareas que retrasan las respuestas. Ahí el canvas es el culpable y el worker salva a la página. Si no estás en ninguno de los dos casos, lo que tienes es una función de dibujo cara, y eso se arregla dibujando menos. Un detalle final de método: cuando midas después de migrar, el hilo principal parecerá magníficamente sano y estarás midiendo mal, porque el trabajo real ya no está ahí. En el panel de rendimiento hay que grabar con el hilo del worker visible y comparar la duración de sus fotogramas, no la del hilo principal.

El criterio

flowchart TB
inicio[El canvas o la pagina van a tirones] --> medir[Medir el hilo principal en el panel de rendimiento]
medir -->|La GPU esta saturada y el hilo tiene huecos| gpu[Reducir pixeles capas y sombras. El worker no ayuda]
medir -->|El dibujo tarda pocos milisegundos| otro[El problema esta en otra parte de la pagina]
medir -->|El dibujo ocupa una parte grande de cada fotograma| opt[Optimizar el dibujo primero]
opt -->|Sigue siendo caro y la animacion es continua| worker[Mover a un worker con transferControlToOffscreen]
opt -->|Ya cabe en el presupuesto| listo[Dejarlo en el hilo principal]
style inicio fill:#89b4fa,color:#11111b
style medir fill:#cba6f7,color:#11111b
style gpu fill:#fab387,color:#11111b
style otro fill:#f9e2af,color:#11111b
style opt fill:#f9e2af,color:#11111b
style worker fill:#a6e3a1,color:#11111b
style listo fill:#a6e3a1,color:#11111b

Traducido a una regla que se puede aplicar sin discusión: mueve el dibujo a un worker cuando la animación es continua, el dibujo ya está optimizado, sigue costando una fracción apreciable del fotograma y la página tiene otras cosas que hacer al mismo tiempo. Un juego, un mapa interactivo, un editor con vista previa en vivo, una visualización que anima mientras el usuario escribe en un formulario.

No lo muevas cuando el canvas dibuja de vez en cuando —un gráfico que se redibuja al cambiar un filtro—, cuando la interacción es el noventa por ciento del uso, o cuando el equipo objetivo tiene pocos núcleos. En esos casos el coste de arquitectura se paga entero y el beneficio es cero.

⚔️ Reto práctico

Coge una escena que dibuje en unos seis milisegundos por fotograma. Mídela con el hilo principal libre y con una tarea sintética que lo bloquee doscientos milisegundos cada segundo. Repite las dos medidas con el dibujo dentro de un worker. Los cuatro números te dan, en tu propio hardware, exactamente cuánto vale la independencia de hilo.