Tres cuartas partes de mi SDK eran código que casi nadie ejecutaba

rendimiento

Si incrustas un script de terceros, estás gastando el ancho de banda de tus usuarios según el criterio de otro. Yo no había sido cuidadoso con el mío.

El widget de BugDump se distribuía como un único archivo de 450 KB (117 KB con gzip) a cada visitante de cada página que lo incrustaba. Lo pagaban los lectores anónimos de una página de marketing. Lo pagaba gente que nunca se fijó en el botón. Es mucho pedir por una función que la mayoría de los visitantes nunca toca.

Así que medí qué había de verdad ahí dentro.

La medición

esbuild te dice exactamente adónde fueron los bytes de un bundle si le pides un metafile. El mío:

Bytes minificados

Proporción

html2canvas-pro

248 965

55,5 %

Mi propio código

117 162

26,1 %

@rrweb/record

75 064

16,7 %

@rrweb/packer

7077

1,6 %

El setenta y dos por ciento del bundle eran dos librerías, y ambas pertenecían a funciones que solo se ejecutan cuando alguien hace algo concreto. html2canvas genera una captura de pantalla de la página. rrweb graba la repetición de sesión. Ninguna tiene nada que hacer mientras un visitante lee un post del blog.

La idea obvia es ofrecer dos builds: uno «completo» y otro «lite» sin las funciones pesadas. La descarté, por un motivo que merece decirse sin rodeos: es una respuesta en tiempo de build a una pregunta de tiempo de ejecución. html2canvas hace falta en el momento en que un usuario hace clic en «Captura», y eso no lo predice ningún flag de build. Un cliente con el build completo seguiría pagando 249 KB en cada carga de página por un botón que la mayoría de sus visitantes nunca pulsa. Y un cliente con el build lite cuyo plan incluye la repetición de sesión tendría una función que en silencio no hace nada, sin que el código pueda detectar el desajuste.

La mejor respuesta es mantener un solo build y cambiar cuándo se carga cada cosa.

Dividir un build que no se puede dividir

Aquí se puso interesante. Mi build para la etiqueta script es un IIFE: un único bundle que se ejecuta a sí mismo, el formato que necesitas cuando no hay un cargador de módulos en la página. esbuild solo admite code splitting para salida ESM. splitting: true no hace nada con IIFE.

Pero el splitting y el import dinámico son cosas distintas. Un import() dinámico funciona sin problema dentro de un script clásico; solo las sentencias import/export requieren type="module". La trampa está en que esbuild resuelve lo que puede en tiempo 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);

El especificador tiene que ser un valor en tiempo de ejecución. Ese es todo el truco, y por eso nadie se da cuenta de que la opción existe: todo el mundo escribe la versión literal, ve cómo el módulo acaba dentro del bundle y concluye que un IIFE no se puede dividir.

Queda una pregunta: en tiempo de ejecución, ¿desde qué URL importas? Un script de terceros no sabe desde dónde se sirvió, a menos que lo pregunte:

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 es la pieza con la que hay que tener cuidado. Solo tiene valor mientras se ejecuta el código de nivel superior del script. Léelo dentro de un handler de clic y obtienes null. Así que se captura una sola vez, en el ámbito del módulo, en una constante, y todo lo demás usa esa constante.

El resultado, con el mismo build de tres archivos:

Archivo

Con gzip

Se carga cuando

Entrada

28 KB

En cada página

Chunk de capturas

62 KB

Alguien hace una captura del DOM

Chunk de repetición

27 KB

El plan incluye repetición de sesión

La entrada pasó de 117 KB a 28 KB con gzip. No se quitó nada.

El bug que destapó la división

Mientras conectaba el chunk de repetición, encontré algo que llevaba tiempo mal sin hacer ruido.

La repetición de sesión es una función de pago. El código antiguo arrancaba el grabador nada más inicializarse, luego preguntaba a mi API si el plan del proyecto incluía la repetición y llamaba a stop() si no la incluía. Funcionalmente correcto. Pero significaba que cada cliente sin repetición de sesión descargaba 82 KB de rrweb, arrancaba un grabador del DOM y luego lo apagaba.

Hacer que la descarga dependiera de esa misma respuesta resolvió los dos problemas a la vez. Ahora el chunk se pide solo después de que el servidor diga que sí.

Hay una contrapartida real: la repetición empieza un viaje de ida y vuelta más tarde que antes. La acepté, porque el grabador mantiene una ventana móvil con snapshots completos periódicos: los primeros cientos de milisegundos de mutaciones del DOM no son aquello en lo que se apoya un reporte de bug. Los recolectores de consola, red y acciones son ligeros, se quedan en la entrada y siguen cubriendo desde el primer momento.

Lo que creía sobre defer y era falso

Di por hecho que la solución para una etiqueta script que bloquea era defer. Es el consejo estándar. Y para este caso, también es incorrecto.

Monté una página similar a una de producción, limitada a 2 Mbps con 100 ms de latencia, y medí las tres variantes. Mediana de tres ejecuciones:

Etiqueta script

domInteractive

DOMContentLoaded

Sin atributo

290 ms

290 ms

defer

56 ms

287 ms

async

58 ms

58 ms

defer libera el parser: domInteractive se desploma. Pero la especificación dice que un script diferido se ejecuta antes de que se dispare DOMContentLoaded, así que DCL sigue esperando a que termine toda la descarga. 287 ms frente a 290 ms no es ninguna mejora.

async libera ambos, porque un script async puede ejecutarse en cuanto llega, sin ninguna garantía de orden. Es justo el intercambio adecuado para un widget autónomo: nada en la página necesita que se haya ejecutado en un momento concreto.

Muchas apps cuelgan su propio arranque de DOMContentLoaded. Si incrustas un script de terceros con defer y crees que ya no bloquea, has movido el retraso de sitio, no lo has eliminado.

Mi snippet ahora es así:

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

Con esto viene una advertencia: si inicializas manualmente desde un script inline, async significa que el SDK puede no existir todavía. Envuelve la llamada en un listener de load: ese evento solo se dispara después de que se hayan ejecutado los scripts async.

Lo que me habría gustado saber antes

Mide antes de teorizar. Podría haber pasado una semana adivinando qué partes pesaban. El metafile lo respondió con un solo comando, y la respuesta (72 % en dos librerías opcionales) apuntaba directamente a la solución.

Prefiere decisiones en tiempo de ejecución a decisiones en tiempo de build. Cada vez que te veas diseñando una matriz de variantes de build, comprueba si aquello según lo cual ramificas se puede saber de verdad en tiempo de build. En mi caso, no se podía.

Un feature flag que llega después de la descarga no es un feature flag. Si el servidor ya sabe si un cliente tiene derecho a algo, esa respuesta debería controlar los bytes, no solo el comportamiento.

Verifica los consejos, también los tuyos. defer es un buen consejo para muchos scripts. No lo era para este, y solo lo descubrí porque medí en lugar de fiarme de la sabiduría heredada, incluida la mía.


El nuevo SDK ya está en producción. Si lo cargas desde bugdump.com, ya lo tienes; añade async a tu etiqueta cuando te venga bien. Si alojas el SDK tú mismo, ten en cuenta que ahora son tres archivos en lugar de uno y que deben estar en el mismo directorio: la referencia de configuración del widget tiene los detalles.

Te puede interesar