Meio segundo de tela congelada: a main thread é o gargalo do seu produto de IA
Rodei a mesma conta na main thread e num Web Worker: a tela congelou até 617 ms de um lado e ficou em 16,8 ms do outro, gastando a mesma CPU. Parsear stream, markdown e JSON de LLM no lugar errado custa experiência e, no fim, token.
Rodei cinco vezes a mesma conta na minha demo e a tela congelou entre 467 ms e 617 ms em todas. Quando a mesma conta foi para um Web Worker, o pior frame ficou em 16,8 ms. Nenhum modelo envolvido: o travamento era só JavaScript na main thread.
Em produto de IA isso aparece toda hora. A resposta do LLM chega em stream e alguém precisa trabalhar em cima dela: markdown que vira HTML, JSON de tool call, bloco de código para destacar. Esse trabalho cresce com a resposta e roda no mesmo lugar que pinta a tela e responde ao clique.
O que a demo mede
É o segundo item da série Frontend com IA: 02-main-thread, em TypeScript estrito. Ela tokeniza um texto de 27.719 caracteres 8.000 vezes, com o mesmo workload.ts importado pelos dois lados, e uma barrinha animada em cima para mostrar quem trava. Este link roda a comparação ao abrir.
O lado do Worker manda o texto como buffer transferido:
const encoded = new TextEncoder().encode(text);
const buffer = encoded.buffer;
worker.postMessage({ buffer, rounds }, [buffer]); Antes do postMessage o buffer tinha 27.999 bytes. Depois, 0 na main thread. A memória mudou de dono sem cópia.
Rodei a página publicada cinco vezes no Chrome headless. Main thread: 490 a 629 ms de CPU, pior frame de 467 a 617 ms, uma long task por rodada. Worker: 500 a 590 ms de CPU, pior frame entre 16,7 e 16,8 ms, nenhuma long task. O número muda com a máquina (o card em /projects registra 333 ms numa medição anterior), mas o formato se repete.
O Worker não acelerou nada
Repara na CPU: igual dos dois lados. O Worker só tira a conta do caminho de quem pinta o frame e trata o clique. Para o web.dev, qualquer tarefa acima de 50 ms é long task, e um INP bom fica em 200 ms ou menos. Meio segundo travado passa disso com folga.
Como Tech Lead, eu decidiria assim:
- Medir antes de mover. A demo conta long tasks com um observer simples, que dá para levar para produção:
this.observer.observe({ type: 'longtask', buffered: true }); - Parse pesado de resposta vai para Worker. Markdown, highlight e JSON grande saem da main thread quando a medição mostra long task. A tela recebe pronto.
- Transferir em vez de copiar quando o dado é grande.
Onde isso vira plataforma de IA
Tela congelada parece resposta travada. A reação natural é clicar em gerar de novo ou recarregar a página, e cada nova geração é token pago. Um problema de JavaScript vira linha na conta do provedor.
O formato do stream também é decisão da plataforma. Se o cliente precisa reparsear o markdown inteiro a cada token, o trabalho cresce junto com a resposta. Eu pediria ao time de Engenharia de IA eventos com tipo (texto, tool call, fim de bloco) e JSON de tool call fechado antes de ir para a tela. Contrato de stream bem desenhado deixa o frontend de agentes de IA mais barato de rodar.
A pergunta que eu levaria para a mesa: quando a resposta mais longa do produto chega, quanto tempo a tela fica sem responder ao clique? Se ninguém mediu, a demo está aberta no repositório frontend-ia-demos, e as outras demos da série estão em eliseu.dev/projects.