Saltar al contenido
ROXEX Labs
Desarrollo Web

Cómo bajamos el TBT de nuestra web: de 750 a 153 ms en la página de servicios

Build log: qué cambiamos en roxex.es para bajar el Total Blocking Time, cómo lo medimos con Lighthouse, qué trampas nos encontramos y qué falta en la home.

Por Levi Olivare · ROXEX Labs15 min de lectura
Rendimiento webTotal Blocking TimeLighthouseCore Web VitalsNext.jsJavaScriptBuild log
Desarrollo Web

En las últimas rondas de trabajo sobre roxex.es nos centramos en una sola métrica: el Total Blocking Time (TBT), el tiempo que el hilo principal del navegador pasa bloqueado durante la carga y en el que la página no puede responder a un toque. En la página de servicios bajó de 750 a 153 ms en Lighthouse móvil. Este artículo cuenta qué cambiamos, con fragmentos del código real, cómo lo medimos, las dos trampas de medición que nos hicieron perder tiempo y lo que todavía no está resuelto, que es la home.

Todas las cifras salen de mediciones hechas en este proyecto: las medianas de tres ejecuciones de Lighthouse por página y los informes JSON que guardamos de cada ejecución. Lo que no hemos medido no aparece.

Qué mide el TBT y por qué lo elegimos

Mientras una página carga, el navegador ejecuta tareas en el hilo principal: interpretar el HTML, calcular estilos, maquetar, pintar y evaluar JavaScript. Si una tarea dura más de 50 ms, cualquier toque o pulsación del usuario tiene que esperar a que termine. El TBT suma, de cada tarea larga entre el primer pintado con contenido (FCP) y el momento en que la página queda interactiva, la parte que pasa de esos 50 ms. Una tarea de 120 ms aporta 70 ms; una de 40 ms no aporta nada.

Lo elegimos por tres motivos:

  • •Es la métrica que más pesa en la nota de rendimiento de Lighthouse 12: un 30 % del total, por delante del LCP y del CLS.
  • •Era la que peor teníamos. En móvil, Lighthouse considera rápido un TBT de hasta 200 ms, y /servicios estaba en 750.
  • •Anticipa cómo responde la página a las interacciones. El TBT es una métrica de laboratorio. La que Google recoge de usuarios reales es INP (Interaction to Next Paint), y un hilo principal bloqueado durante la carga suele acabar en interacciones lentas.

Los números

PáginaNota antesNota despuésTBT antesTBT después
/servicios7896750 ms153 ms
/contact8995370 ms191 ms
Artículo del blog (/blog/5-procesos-automatizar-ia-empresa)8596440 ms145 ms
Home7386640 ms364 ms

En la home también bajó el LCP (el momento en que se pinta el elemento principal de la pantalla), de 3,0 a 2,07 s.

Condiciones de todas las filas: Lighthouse 12 en modo móvil, con el perfil de red y CPU por defecto (CPU cuatro veces más lenta y red tipo 4G lenta) aplicado con throttling de DevTools, tres ejecuciones por página y la mediana, sobre una copia de la build estática servida en local. La columna «antes» es la build anterior a estos cambios; la columna «después», la build final de este trabajo, medida el 10 de noviembre de 2026. Más abajo explicamos por qué cada uno de esos detalles importa.

La fila del blog es un artículo concreto, y no vale para todos: los artículos largos con tablas y bloques de código cuestan más. Este mismo dio, en la misma tanda, una mediana de 268 ms de TBT (nota 92), y 410 y 338 ms en dos tandas anteriores sobre una build previa del mismo día. En su reparto del hilo principal domina el pintado (unos 8,2 s con la CPU ralentizada), no la evaluación de JavaScript (0,46 s).

Cómo medimos, y dos trampas que nos costaron tiempo

Trampa 1: el throttling simulado contra un servidor local

Lighthouse puede limitar la red y la CPU de dos maneras. La que usa por defecto, simulada (con un modelo llamado Lantern), carga la página a toda velocidad y después estima cómo habría ido en un móvil lento. La otra, el throttling de DevTools, limita la red y la CPU de verdad mientras la página carga.

Al principio medíamos con el modo por defecto contra el servidor local. El modelo calcula a partir de lo que observa en esa carga rápida, y contra 127.0.0.1 la red casi no existe. En /contact, el LCP simulado no se parecía a lo que mostraba la traza de esa misma ejecución, en la que el contenido principal aparecía mucho antes. Perseguir una estimación que no coincide con la traza es perder el tiempo, así que pasamos a medirlo todo con --throttling-method=devtools.

Una consecuencia práctica: PageSpeed Insights usa el modo simulado y otra máquina, de modo que si pasas nuestra web por ahí verás cifras distintas a las de la tabla. Ninguna de las dos es "la verdadera": son mediciones de laboratorio con condiciones distintas, y solo tiene sentido comparar cifras tomadas con el mismo método.

Trampa 2: OneDrive y un ordenador ocupado

El throttling de DevTools tiene su propio punto débil: ralentiza la CPU cuatro veces respecto a lo que el equipo puede dar en ese momento. Si el equipo está ocupado con otra cosa, la página sale más lenta sin que la página tenga culpa.

Nuestro proyecto vive dentro de una carpeta sincronizada con OneDrive. Cada next build reescribe la carpeta out/ (más de 2.000 archivos en la copia que medimos) y el cliente de OneDrive se pone a sincronizarlos, con su consumo de CPU y disco, justo cuando queríamos medir. Por eso ahora copiamos out/ a una carpeta temporal fuera de OneDrive y la servimos desde ahí.

Lighthouse guarda en cada informe un benchmarkIndex: una prueba rápida de la potencia de la CPU al empezar, en la que más alto significa más rápido. Revisando los informes encontramos el caso más claro de todos: la misma build de /servicios, con los mismos archivos JavaScript (mismos hashes en los nombres de los chunks), medida en dos tandas.

TandabenchmarkIndex de las 3 ejecucionesTBT de las 3 ejecucionesMediana del TBT
Primera2.004 / 2.093 / 2.0282.453 / 481 / 1.227 ms1.227 ms
Segunda3.454 / 3.373 / 3.466146 / 142 / 159 ms146 ms

El código era idéntico. Lo único que cambió fue la CPU disponible, y con ella la mediana del TBT se multiplicó por más de ocho. Además, incluso con el equipo en buenas condiciones hay ruido: en otra tanda, tres ejecuciones de /contact sobre la misma build, alternadas con las de otra versión, dieron 234, 243 y 114 ms. Una diferencia de unas decenas de milisegundos entre dos medianas no demuestra nada.

El método que usamos ahora

  • •Build de producción (next build con export estático), copiada fuera de OneDrive y servida en local.
  • •Lighthouse 12 en modo móvil, solo la categoría de rendimiento, con throttling de DevTools.
  • •Tres ejecuciones por página. Publicamos la mediana, nunca la mejor.
  • •Nada más en marcha mientras tanto: ni otras builds ni servidores de desarrollo. Antes de creernos una tanda miramos el benchmarkIndex de cada informe.
  • •Para comparar dos versiones, servimos las dos a la vez en puertos distintos y alternamos el orden en cada ejecución (antes y después, después y antes), para que una racha de carga afecte a las dos por igual.

La llamada a Lighthouse queda así:

bash
lighthouse http://127.0.0.1:3202/servicios \ --only-categories=performance \ --throttling-method=devtools \ --form-factor=mobile \ --output=json --quiet \ --chrome-flags="--headless=new --no-first-run --disable-extensions"

Cambio 1: fuera GSAP, animaciones nativas

La web usaba GSAP para las animaciones de entrada de la cabecera, el menú y las páginas interiores. GSAP es una librería excelente, pero aquí solo hacía animaciones de una sola vez: desplazar un bloque unos píxeles y dejarlo en su sitio. Así entraba un artículo del blog:

tsx
const ctx = gsap.context(() => { gsap.fromTo(el.children, { opacity: 0, y: 30 }, { opacity: 1, y: 0, stagger: 0.1, duration: 0.7, ease: "power3.out" }); }, el); return () => ctx.revert();

Había dos problemas. GSAP iba en el JavaScript que carga cada página interior, y la animación partía de opacity: 0: el artículo era invisible hasta que el JavaScript se descargaba, se ejecutaba y lanzaba la animación, lo que retrasa el LCP.

Lo sustituimos por un módulo propio de 116 líneas, nativeMotion.ts, sobre la Web Animations API (Element.animate()), que el navegador trae de serie. Esta es la función principal, sin comentarios y sin el try/catch que la envuelve:

ts
export function animateFrom( targets: Targets, { x = 0, y = 0, opacity, scale, duration = 0.6, stagger = 0, delay = 0, ease = EASE_POWER3_OUT }: FromOptions ): () => void { const els = toArray(targets); if (!els.length || !canAnimate() || prefersReducedMotion()) return () => {}; // ...construye el fotograma inicial `from` (translate y, si se pide, opacity o scale) const anims: Animation[] = []; els.forEach((el, i) => { anims.push( el.animate([from], { duration: duration * 1000, delay: (delay + i * stagger) * 1000, easing: ease, fill: "backwards", }) ); }); return () => anims.forEach((a) => a.cancel()); }

Tres detalles. Solo se da el fotograma inicial: el final es el estado que el CSS ya define, así que al acabar no queda ningún estilo en línea. Con prefers-reduced-motion no se anima nada. Y devuelve una función de cancelación que encaja directamente como limpieza de un useEffect. El artículo del blog ahora entra así:

tsx
const stopHeader = animateFrom(header.children, { y: 20, stagger: 0.08, duration: 0.6 }); const stopContent = animateFrom(content, { y: 16, duration: 0.6, delay: 0.15, ease: EASE_POWER2_OUT });

Fíjate en que ya no hay opacity: la regla que seguimos es que una animación de entrada mueve el contenido, pero nunca lo oculta. Para los botones con efecto magnético, que usaban la curva elástica de GSAP, muestreamos esa curva en 41 puntos y la pasamos a la función CSS linear(); donde el navegador no la soporta se usa una cubic-bezier con algo de rebote.

Resultado: GSAP salió de las dependencias del proyecto y el JavaScript de cada página bajó unos 26 KiB comprimidos con gzip.

Cambio 2: el menú ya no arrastra el catálogo de servicios

El archivo navigation.ts lo importan la cabecera, el menú y los pies de página: componentes de cliente presentes en todas las páginas. Los enlaces a servicios salían del catálogo completo (services.ts), que contiene todos los textos de las páginas de servicio. Al importarlo desde un componente de cliente, ese catálogo entraba en el JavaScript de cada página aunque el menú solo necesitara cinco nombres y cinco rutas.

La solución fue una copia mínima, services-nav.ts:

ts
import type { ServiceSlug } from "@/data/services"; export const SERVICES_NAV = [ { slug: "desarrollo-software-a-medida", es: "Software a medida", en: "Custom software", href: "/servicios/desarrollo-software-a-medida" }, // ...los otros cuatro servicios ] as const satisfies readonly ServiceNavItem[];

La clave es import type: TypeScript lo borra al compilar, así que del catálogo solo se usa el tipo y no viaja ni un byte al navegador. Un import normal de un valor de services.ts volvería a meter el catálogo entero.

Duplicar datos tiene un riesgo evidente: que la copia se quede atrás. Lo cubrimos por dos lados. Un tipo que hace fallar npm run typecheck si falta algún servicio:

ts
type AssertNever<T extends never> = T; export type ServicesNavIsComplete = AssertNever<Exclude<ServiceSlug, (typeof SERVICES_NAV)[number]["slug"]>>;

Y un script, scripts/check-services-nav.mjs, que compara la copia con el catálogo (orden, slug, ruta y título corto) y falla si no coinciden.

Cambio 3: cada artículo del blog carga solo su artículo

Cada artículo se genera como HTML estático, pero la parte interactiva es un componente de cliente. Antes, la página solo le pasaba el slug y el componente buscaba el artículo por su cuenta:

tsx
// Antes: el componente de cliente importaba los datos del blog import { getPostBySlug, allBlogPosts, getPostGradient, isFeaturedPost } from "@/data/blog-posts"; // ... return <BlogPostClient slug={slug} />;

Ese import llevaba todos los artículos del blog, con su texto completo en dos idiomas, al JavaScript de cada artículo. Ahora la página, que se ejecuta al compilar, elige el artículo y los relacionados, y al cliente solo le llegan los datos que va a pintar:

tsx
const related = [ ...sameCat.filter((p) => isFeaturedPost(p.slug)), ...sameCat.filter((p) => !isFeaturedPost(p.slug)), ] .slice(0, 3) .map(toSummary); return <BlogPostClient post={post} related={related} featured={isFeaturedPost(slug)} />;

toSummary quita el cuerpo de los artículos relacionados (sus tarjetas no lo necesitan), y el componente de cliente importa de blog-posts únicamente tipos. Este cambio lo hicimos en una ronda anterior y fue el de mayor efecto en tamaño: el JavaScript de un artículo pasó de 1.040 a 237 KiB comprimidos con gzip. Lo incluimos porque es el mismo error que el del menú: un componente de cliente que importa un módulo de datos se lleva el módulo entero.

Cambio 4: el aviso de cookies se pinta con la página

Este no es de TBT sino de LCP, pero salió de la misma investigación. El aviso de cookies se montaba después de la hidratación, así que en una primera visita llegaba tarde y era el elemento más grande de la pantalla: Lighthouse lo tomaba como LCP. Ahora va en el HTML estático y se pinta con el resto de la página. Para que quien ya eligió no lo vea ni un instante, un script mínimo en el <head> marca el documento antes del primer pintado:

html
<script>try{if(localStorage.getItem("…"))document.documentElement.dataset.consent="1"}catch(e){}</script>

Y una regla de CSS lo esconde:

css
html[data-consent] .roxex-cookie-banner { display: none; }

El try/catch está porque localStorage puede lanzar una excepción con el almacenamiento bloqueado; en ese caso el aviso simplemente se muestra.

Barandillas para que no vuelva a pasar

Cada cambio de los anteriores se puede deshacer sin querer con una línea, así que lo acompañamos de una comprobación automática. npm run verify encadena el chequeo de tipos, la comprobación del menú de servicios, la build y varias auditorías de la carpeta out/. Una de ellas, scripts/check-out-complete.mjs, nació de un incidente: una vez se subió a producción una carpeta out/ a la que le faltaban los archivos de public/ (logos, imágenes para redes, robots.txt, cabeceras y redirecciones) y la web se rompió. El script comprueba que cada archivo de public/ existe en out/ con el mismo tamaño:

js
for (const rel of publicFiles) { const src = path.join(PUBLIC, rel); const dst = path.join(OUT, rel); if (!fs.existsSync(dst) || !fs.statSync(dst).isFile()) { missing.push(rel); continue; } const a = fs.statSync(src).size; const b = fs.statSync(dst).size; if (a !== b) sizeMismatch.push(`${rel} (public ${a} B, out ${b} B)`); }

Tiene relación con este artículo porque medir obliga a copiar out/ de un sitio a otro, y es fácil acabar midiendo, o subiendo, una carpeta incompleta.

Lo que estas cifras no dicen

  • •Son de laboratorio, no de usuarios reales. Los Core Web Vitals que ve Google salen de datos de campo (el Chrome UX Report, CrUX), y no tenemos datos de campo de roxex.es que confirmen esta mejora. No podemos afirmar que los usuarios reales tengan mejor INP.
  • •Una máquina, un navegador, tres ejecuciones. La mediana de tres es razonable para detectar cambios grandes como estos, no para pelear por 20 ms.
  • •Servidor local, no la red de producción. La parte de red no representa lo que pasa cuando la web se sirve desde la CDN.
  • •Cambiamos varias cosas y medimos el conjunto. No tenemos una medición aislada de cada cambio, así que no atribuimos milisegundos concretos a cada uno.
  • •Las notas son de Lighthouse 12. Otras versiones ponderan las métricas de otra forma.

Lo que falta: la home

En la build final, la home ha mejorado (nota de 73 a 86, TBT de 640 a 364 ms, LCP de 3,0 a 2,07 s), pero sigue por encima de los 200 ms de TBT que Lighthouse considera rápidos. También le queda un desplazamiento de diseño: cuando llega la fuente web, el titular cambia de ancho y mueve el bloque que tiene debajo (CLS de 0,08 de mediana en esa tanda). Y aquí quitar JavaScript ya no basta. En una de las tandas comparativas, la mediana del reparto del trabajo del hilo principal durante la carga (con la CPU ralentizada cuatro veces) fue esta:

Tipo de trabajoTiempo
Otros (sin categoría)5.209 ms
Estilos y maquetación3.522 ms
Pintado y composición1.649 ms
Evaluación de JavaScript827 ms

La evaluación de JavaScript es la menor de las cuatro partidas grandes. El trabajo de estilos y maquetación es más de cuatro veces mayor, así que el siguiente paso no es recortar más scripts, sino averiguar qué partes de la página cuestan maquetar. Para eso hemos preparado un script que carga la home con la CPU ralentizada y modifica el HTML y el CSS al vuelo antes de que lleguen al navegador: oculta una sección cada vez, desactiva el ajuste de líneas equilibrado de los titulares o aplica content-visibility: auto a lo que queda por debajo de la primera pantalla. Así podemos ver qué cambio mueve el tiempo de maquetación antes de tocar el código. Cuando tengamos resultados medidos con el mismo método, los contaremos aquí.

Qué nos llevamos al trabajo con clientes

Casi nada de esto es exclusivo de nuestra web. Medir antes de cambiar y siempre con el mismo método, publicar medianas y no el mejor resultado, quitar antes que optimizar, desconfiar de los componentes de cliente que importan módulos de datos y dejar una comprobación automática junto a cada arreglo. Es lo que aplicamos cuando construimos software a medida y lo primero que revisamos en una web lenta. Si quieres el contexto general sobre por qué importa la velocidad, lo tienes en velocidad de carga web, y otro build log con la misma filosofía en cómo construimos el simulador del Mundial 2026. Si tu web tiene un problema parecido, cuéntanoslo.

Preguntas frecuentes

¿Qué es un buen TBT? En la documentación de Lighthouse, en móvil, un TBT de hasta 200 ms se considera rápido. Ten en cuenta que el TBT depende mucho de la máquina y del método: compara siempre cifras tomadas en las mismas condiciones.

¿El TBT es una Core Web Vital? No. Las Core Web Vitals son LCP, INP y CLS, y se miden con usuarios reales. El TBT es una métrica de laboratorio que sirve como aproximación a la capacidad de respuesta mientras no hay datos de campo.

¿Por qué PageSpeed Insights me da cifras distintas a las vuestras? Porque usa el throttling simulado y sus propios servidores, y nosotros medimos con throttling de DevTools en un equipo local. Son métodos distintos; lo útil es comparar cada método consigo mismo, antes y después de un cambio.

¿Hay que quitar GSAP siempre? No. Si una web usa líneas de tiempo complejas, animaciones ligadas al scroll o secuencias encadenadas, GSAP compensa. Lo quitamos porque aquí solo hacía animaciones de entrada sencillas que el navegador ya sabe hacer solo.

¿Cuánto tarda en notarse en Google? No lo sabemos todavía. Los datos de campo de CrUX se agregan sobre periodos de 28 días y solo se publican para sitios con un volumen mínimo de visitas. Mientras no tengamos datos de campo de roxex.es, no podemos medir ese efecto.

Fuentes

  • •Chrome for Developers — Total Blocking Time (Lighthouse). https://developer.chrome.com/docs/lighthouse/performance/lighthouse-total-blocking-time
  • •Chrome for Developers — Lighthouse performance scoring. https://developer.chrome.com/docs/lighthouse/performance/performance-scoring
  • •Lighthouse (GitHub) — Throttling. https://github.com/GoogleChrome/lighthouse/blob/main/docs/throttling.md
  • •Lighthouse (GitHub) — Score variability. https://github.com/GoogleChrome/lighthouse/blob/main/docs/variability.md
  • •web.dev — Total Blocking Time (TBT). https://web.dev/articles/tbt
  • •web.dev — Interaction to Next Paint (INP). https://web.dev/articles/inp
  • •Chrome for Developers — Chrome UX Report. https://developer.chrome.com/docs/crux
  • •MDN — Element.animate(). https://developer.mozilla.org/en-US/docs/Web/API/Element/animate

¿Hablamos de tu proyecto?

Una llamada de 30 minutos, gratis y sin compromiso, con quien escribiría el código.