Três quartos do meu SDK eram código que quase ninguém executava

replay de sessão

Se você incorpora um script de terceiros, está gastando a banda dos seus usuários com o julgamento de outra pessoa. Eu não tinha tomado esse cuidado com o meu.

O widget do BugDump era entregue como um único arquivo de 450 KB (117 KB com gzip) a cada visitante de cada página que o incorporava. Leitores anônimos de uma página de marketing pagavam esse preço. Pessoas que nunca notaram o botão pagavam esse preço. É muito para cobrar por um recurso que a maioria dos visitantes nunca usa.

Então medi o que havia lá dentro de fato.

A medição

O esbuild diz exatamente para onde foram os bytes de um bundle se você pedir um metafile. O meu:

Bytes minificados

Participação

html2canvas-pro

248.965

55,5%

Meu próprio código

117.162

26,1%

@rrweb/record

75.064

16,7%

@rrweb/packer

7.077

1,6%

Setenta e dois por cento do bundle eram duas bibliotecas, e ambas pertenciam a recursos que só rodam quando alguém faz algo específico. O html2canvas renderiza uma captura de tela da página. O rrweb grava o replay de sessão. Nenhum dos dois tem trabalho a fazer enquanto um visitante lê um post do blog.

A ideia óbvia é oferecer dois builds: um «completo» e um «leve», sem os recursos pesados. Eu a descartei, por um motivo que vale dizer com todas as letras: essa é uma resposta de build para uma pergunta de runtime. O html2canvas é necessário no momento em que o usuário clica em «Captura», e nenhuma flag de build prevê isso. Um cliente no build completo continuaria pagando 249 KB a cada carregamento de página por um botão que a maioria dos visitantes nunca aperta. E um cliente no build leve cujo plano inclui replay de sessão receberia um recurso que silenciosamente não faz nada, sem que o código tivesse como detectar a incompatibilidade.

A resposta melhor é manter um build só e mudar quando as coisas carregam.

Dividir um build que não pode ser dividido

Aqui a coisa ficou interessante. O build para a tag de script é um IIFE: um único bundle autoexecutável, o formato de que você precisa quando não há carregador de módulos na página. O esbuild só faz code splitting para saída ESM. splitting: true não faz nada para IIFE.

Mas splitting e import dinâmico são coisas diferentes. Um import() dinâmico funciona bem dentro de um script clássico; só as declarações import/export exigem type="module". O problema é que o esbuild resolve o que consegue em tempo de build:

js
// esbuild resolves this and folds the module back into the bundle
const mod = await import('./heavy');

// esbuild cannot resolve this, so it survives as a real runtime import
const mod = await import(someUrl);

O especificador precisa ser um valor de runtime. Esse é o truque todo, e é por isso que ninguém percebe que a opção existe: todo mundo escreve a versão literal, vê o módulo ser embutido no bundle e conclui que um IIFE não pode ser dividido.

Sobra uma pergunta: em runtime, de que URL importar? Um script de terceiros não sabe de onde foi servido, a menos que pergunte:

js
const BASE = document.currentScript.src;   // https://bugdump.com/sdk/latest.js
const url = new URL('./bugdump-replay.js', BASE).href;
const { record } = await import(url);

document.currentScript é a parte que pede cuidado. Ele só está definido enquanto o código de nível superior do script está rodando. Leia-o dentro de um handler de clique e você recebe null. Por isso ele é capturado uma vez, no escopo do módulo, numa constante, e tudo o que vem depois usa essa constante.

O resultado, do mesmo build de três arquivos:

Arquivo

Com gzip

Carrega quando

Entrada

28 KB

Em todas as páginas

Chunk de captura de tela

62 KB

Alguém tira uma captura de tela do DOM

Chunk de replay

27 KB

O plano permite replay de sessão

A entrada caiu de 117 KB para 28 KB com gzip. Nada foi removido.

O bug que a divisão revelou

Enquanto ligava o chunk de replay, encontrei algo que estava errado sem ninguém perceber.

O replay de sessão é um recurso pago. O código antigo iniciava o gravador logo no init, depois perguntava à API se o plano do projeto incluía replay e chamava stop() se não incluísse. Funcionalmente correto. Mas isso significava que todo cliente sem replay de sessão baixava 82 KB de rrweb, iniciava um gravador de DOM e depois o desligava.

Tornar o download condicional a essa mesma resposta resolveu os dois problemas de uma vez. Agora o chunk só é pedido depois que o servidor diz sim.

Há uma troca real: o replay começa uma ida e volta de rede mais tarde do que antes. Aceitei, porque o gravador mantém uma janela circular com snapshots completos periódicos, e as primeiras centenas de milissegundos de mutações do DOM não são aquilo em que um relatório de bug se apoia. Os coletores de console, rede e ações são baratos, ficam na entrada e continuam cobrindo desde o primeiro momento.

O que eu entendi errado sobre o defer

Eu supunha que a solução para uma tag de script bloqueante era o defer. É o conselho padrão. E, para este caso, está errado.

Montei uma página parecida com uma de produção, limitada a 2 Mbps com 100 ms de latência, e medi as três variantes. Mediana de três execuções:

Tag de script

domInteractive

DOMContentLoaded

Sem atributo

290 ms

290 ms

defer

56 ms

287 ms

async

58 ms

58 ms

O defer libera o parser: o domInteractive despenca. Mas a especificação manda um script adiado rodar antes de o DOMContentLoaded disparar, então o DCL continua esperando o download inteiro. 287 ms contra 290 ms não é uma vitória.

O async libera os dois, porque um script assíncrono pode rodar assim que chega, sem promessa de ordem. É exatamente a troca certa para um widget autocontido: nada na página precisa que ele tenha rodado num momento específico.

Muitos apps penduram a própria inicialização no DOMContentLoaded. Se você incorpora um script de terceiros com defer e acha que o deixou não bloqueante, você mudou o custo de lugar, não o removeu.

O meu snippet agora fica assim:

HTML
<script src="https://bugdump.com/sdk/latest.js" async data-api-key="your-api-key"></script>

Isso vem com uma ressalva: se você inicializa manualmente a partir de um script inline, async significa que o SDK pode ainda não existir. Envolva a chamada num listener de load; esse evento só dispara depois que os scripts assíncronos foram executados.

O que eu diria a mim mesmo antes

Meça antes de teorizar. Eu poderia ter passado uma semana adivinhando quais partes eram pesadas. O metafile respondeu com um comando, e a resposta (72% em duas bibliotecas opcionais) apontou direto para a solução.

Prefira decisões de runtime às de build. Sempre que você se pegar desenhando uma matriz de variantes de build, verifique se aquilo que define o ramo é de fato conhecido em tempo de build. No meu caso, não era.

Uma feature flag que chega depois do download não é uma feature flag. Se o servidor já sabe se um cliente tem direito a algo, essa resposta deve controlar os bytes, não só o comportamento.

Verifique os conselhos, inclusive os seus. defer é um bom conselho para muitos scripts. Não era para este, e só descobri porque medi em vez de confiar no senso comum, inclusive no meu.


O novo SDK já está no ar. Quem carrega o SDK de bugdump.com já está usando a versão nova; acrescente async à sua tag quando puder. Se você hospeda o SDK por conta própria, saiba que agora são três arquivos em vez de um, e eles precisam ficar no mesmo diretório: a referência de configuração do widget traz os detalhes.

Leia também