Los casos donde el trabajador no compensa
Los cinco modos de fallo de una migración a worker, la cuenta de punto de equilibrio con cifras medidas, por qué en un móvil el hilo aparte puede ser más lento, y las alternativas que resuelven el mismo problema más barato.
Mover trabajo a otro hilo es la solución arquitectónicamente limpia, y por eso se aplica muchas veces donde no toca. El resultado típico de una migración mal juzgada no es un desastre visible: es una semana de trabajo, una capa de complejidad permanente, y una métrica que no se mueve. Esta lección es la contraparte de las cuatro anteriores: los criterios para decir que no, la cuenta que lo demuestra, y qué hacer en su lugar.
- Reconocer los cinco modos de fallo típicos de una migración a trabajador.
- Calcular el punto de equilibrio con cifras medidas en el dispositivo de referencia.
- Explicar por qué en un móvil de gama baja el hilo aparte puede ser más lento.
- Elegir la alternativa correcta cuando el trabajador no es la respuesta.
Los cinco modos de fallo
Uno: el trabajo era corto. Una operación de 15 milisegundos no es una tarea larga, no aparece en el TBT y no afecta al INP. Moverla a un trabajador cambia 15 milisegundos de cálculo por entre 1 y 6 de mensajes más la latencia de la respuesta, y el usuario no percibe absolutamente nada. El umbral por debajo del cual no hay nada que ganar está alrededor de los 50 milisegundos, que es la definición de tarea larga; por debajo, el problema no existe.
Dos: la carga útil domina. Ya vimos la aritmética: si serializar la entrada y la salida cuesta más que el cálculo, el trabajador aumenta el bloqueo del hilo principal en lugar de reducirlo. Es el fallo más frecuente y el más fácil de detectar antes de escribir nada, con el banco de clonado de la lección del coste de los mensajes.
Tres: solo has movido una parte. El caso clásico: la interacción calcula algo pesado y después renderiza tres mil filas. Mueves el cálculo, celebras, y el INP no baja porque los tres mil nodos siguen construyéndose en el hilo principal. La proporción importa: si el trabajo movible es el 30% del total de la interacción, el techo de tu mejora es el 30%.
Cuatro: el trabajador se ha vuelto conversador. Un mensaje por elemento convierte el trabajador en una fábrica de tareas cortas para el hilo principal. El TBT sale limpio, ninguna tarea supera los 50 milisegundos, y la página está bloqueada de todas formas.
Cinco: el trabajo no era paralelizable en la práctica. El cálculo depende del resultado de una lectura del DOM, o de una decisión que solo puede tomar el hilo principal, y acabas con una coreografía de ida y vuelta donde los dos hilos se esperan mutuamente. El paralelismo nominal es cero y has pagado toda la complejidad.
La cuenta de punto de equilibrio
Antes de migrar, mide cuatro cifras en el dispositivo de referencia. No estimes ninguna.
| Cifra | Cómo se mide |
|---|---|
T cálculo |
performance.now() alrededor de la operación, en el hilo principal |
Se serializar entrada |
structuredClone de la carga útil de entrada, promedio de 20 repeticiones |
Ss serializar salida |
structuredClone de la carga útil de salida, igual |
V viaje |
Un postMessage de ida y vuelta con carga útil vacía, promedio de 100 |
Lo que desaparece del hilo principal es T. Lo que aparece es Se + Ss + V, porque las dos serializaciones se pagan una vez en cada extremo y el hilo principal es uno de los dos extremos en las dos direcciones.
// El experimento completo, sin migrar nada
async function equilibrio(entrada, operacion) {
const t0 = performance.now();
const salida = operacion(entrada);
const T = performance.now() - t0;
const medir = (v) => {
structuredClone(v);
const a = performance.now();
for (let i = 0; i < 20; i++) structuredClone(v);
return (performance.now() - a) / 20;
};
const Se = medir(entrada);
const Ss = medir(salida);
const c = new MessageChannel();
c.port2.onmessage = () => c.port2.postMessage(0);
const b = performance.now();
for (let i = 0; i < 100; i++) {
await new Promise((r) => { c.port1.onmessage = r; c.port1.postMessage(0); });
}
const V = (performance.now() - b) / 100;
c.port1.close(); c.port2.close();
console.log({ T, Se, Ss, V, ganancia: T - (Se + Ss + V) });
return T - (Se + Ss + V);
}
Si ganancia sale negativa, la migración empeora el hilo principal y no hay nada que discutir. Si sale positiva pero por debajo de unos 50 milisegundos, la migración es correcta y probablemente no valga el coste de mantenimiento.
Y hay un quinto término que la cuenta no captura y que hay que sumar aparte: el arranque. Entre 10 y 40 milisegundos de contexto más el tiempo de descarga y compilación del código del trabajador, que se pagan una vez pero que se pagan en algún momento. Si ese momento cae dentro de la carga crítica, has añadido trabajo a la ventana que más importa.
Por qué en un móvil el hilo aparte puede ser más lento
Este es el punto que rompe la intuición y que no aparece en casi ninguna guía.
navigator.hardwareConcurrency te dice cuántos hilos lógicos declara el sistema. Lo que no te dice es que en un teléfono de gama media o baja esos núcleos no son iguales: la arquitectura habitual combina un par de núcleos grandes de alto rendimiento con cuatro o seis pequeños de bajo consumo, y la diferencia de rendimiento entre unos y otros es de un factor de dos a cuatro.
El hilo principal del renderizador es el hilo que el sistema considera prioritario y el que acaba en un núcleo grande. Un trabajador recién creado es un hilo más, sin historial de uso, y el planificador del sistema tiende a colocarlo en un núcleo pequeño. La consecuencia práctica: el mismo bucle puede tardar dos o tres veces más dentro del trabajador que en el hilo principal, en el mismo dispositivo, en el mismo momento.
Eso no invalida la migración —la latencia total sube, pero el hilo principal queda libre y el INP mejora, que era el objetivo— pero cambia dos decisiones.
Cambia el punto de equilibrio. Si el cálculo tarda el triple, el usuario espera el triple por el resultado. Para un trabajo en segundo plano da igual; para algo que el usuario está esperando ver, importa mucho.
Cambia el tamaño del grupo de trabajadores. El error habitual es crear navigator.hardwareConcurrency trabajadores creyendo que se multiplica el rendimiento por ese número. En un teléfono con ocho núcleos declarados de los cuales seis son pequeños, y con el proceso del navegador ya usando hilos para rasterizado y composición, ocho trabajadores compiten entre sí y con el compositor. El resultado es más lento que con dos.
// Un tamano de grupo defendible, no el numero de nucleos
const nucleos = navigator.hardwareConcurrency || 4;
const tam = Math.max(1, Math.min(nucleos - 1, 4));
La comprobación honesta es medirlo, y se mide con una sola línea de instrumentación dentro del trabajador:
// dentro del worker, alrededor del calculo
const t0 = performance.now();
const r = calcular(datos);
self.postMessage({ r, msDentro: performance.now() - t0 });
Compara msDentro con el tiempo que la misma función tardaba en el hilo principal. Si la relación en tu dispositivo de referencia es peor que 1,5, el trabajador está corriendo en un núcleo pequeño y tienes que decidir si la latencia extra es aceptable.
Hay un patrón de fallo que produce una situación aparentemente imposible: el perfil muestra el hilo principal limpio, no hay ninguna tarea larga, el TBT es cero, y el INP en campo ha subido después de la migración. Lo he visto suficientes veces como para que merezca su propia explicación.
La causa es que el trabajo no desapareció: se convirtió en una tarea que llega en un momento que no controlas.
Antes, el cálculo pesado ocurría dentro del manejador de la interacción. Eso bloqueaba, sí, pero bloqueaba de forma alineada con la interacción: el usuario pulsaba, el hilo trabajaba, el resultado se pintaba. La duración del procesamiento era mala y el resto del INP estaba bien.
Después, el manejador termina en dos milisegundos y la respuesta del trabajador llega 300 milisegundos más tarde como un evento message, que es una tarea nueva en la cola del hilo principal. Si en ese instante el usuario está pulsando otra cosa, esa tarea de mensaje —más el renderizado del resultado, que sigue ocurriendo en el hilo principal— se convierte en el retraso de entrada de la interacción siguiente.
Has trasladado el coste desde la duración del procesamiento de la interacción A hasta el retraso de entrada de la interacción B. El total de trabajo es el mismo, y el INP, que se queda con la peor interacción, puede empeorar porque ahora hay dos interacciones afectadas en lugar de una.
Tres cosas que lo evitan, y las tres son de diseño, no de ajuste.
Uno: el manejador del mensaje tiene que ser tan corto como el manejador de la interacción. Si al recibir el resultado construyes tres mil nodos, has movido el problema, no lo has resuelto. Trocea ese renderizado con cesión del hilo, exactamente igual que trocearías cualquier otro trabajo largo.
Dos: no mandes trabajo al trabajador durante una interacción si puedes mandarlo antes. El patrón de precálculo especulativo —empezar el trabajo al pasar el ratón por encima, al enfocar el campo, al abrir el panel— convierte una espera en una precarga y elimina el problema de raíz.
Tres: mide el INP, no el TBT. Este fallo es invisible en el TBT por construcción, porque ninguna de las tareas resultantes es larga. Solo aparece en el INP de campo, y solo si estás segmentando por interacción. Si tu única señal es la puntuación de una auditoría de laboratorio, la migración parecerá un éxito rotundo mientras los usuarios lo viven peor.
Y el corolario incómodo: una migración a trabajador no está terminada hasta que has medido el INP en campo antes y después. El perfil de laboratorio limpio es condición necesaria y no es suficiente, porque el laboratorio no interactúa con la página mientras el trabajador responde, y el usuario sí.
Qué hacer en su lugar
Cuatro alternativas, ordenadas por lo que suelen dar a cambio de lo que cuestan.
Hacer menos trabajo. La opción que nadie explora lo suficiente. Ordenar cincuenta mil filas para mostrar veinte es un problema de algoritmo, no de hilos: una selección parcial de los veinte primeros cuesta una fracción de una ordenación completa. Memorizar el resultado de un cálculo que se repite con los mismos argumentos cuesta cuatro líneas. Antes de repartir el trabajo entre hilos, comprueba que el trabajo tiene que existir.
Trocear con cesión del hilo. Si el trabajo depende del DOM o si la carga útil es imposible de serializar barato, ceder el hilo no elimina el bloqueo pero lo reparte en trozos que dejan pasar la interacción. Es peor que el trabajador en teoría y muchísimo más barato de implementar y de mantener.
Moverlo al servidor. Ordenar, filtrar y agregar conjuntos de datos grandes es lo que las bases de datos llevan cincuenta años haciendo bien. Si el cliente descarga cincuenta mil filas para mostrar veinte, el problema no es dónde se ejecuta el bucle: es que las cincuenta mil filas han cruzado la red.
Reducir el coste del renderizado en lugar del del cálculo. Cuando la medición dice que el 70% del tiempo de la interacción está en el renderizado y no en el cálculo, ningún trabajador va a ayudar. Ese 70% se ataca con virtualización y con contención.
Y una comprobación final que ahorra mucho tiempo: graba un perfil de la interacción y mira el reparto real antes de decidir. La intuición sobre dónde se va el tiempo acierta menos de la mitad de las veces, y la migración a trabajador es lo bastante cara como para no empezarla con una hipótesis sin comprobar.
Coge la última migración a trabajador que hiciste o que ibas a hacer y ejecuta la función equilibrio con los datos reales, en el dispositivo de referencia y no en tu portátil. Anota las cuatro cifras. Después mide el tiempo del cálculo dentro del trabajador con msDentro y compáralo con el del hilo principal. Con esas seis cifras, la decisión deja de ser una opinión.