Inicio / Artículos / Analizando el lento proceso de compilación de un monorepo con Webpack antes de recurrir a un nuevo bundler

Analizando el lento proceso de compilación de un monorepo con Webpack antes de recurrir a un nuevo bundler

Cómo el análisis de la compilación de un microfrontend con Webpack que duraba 20 minutos reveló el uso de Terser y Babel, las instalaciones necesarias y el caché de Docker, y qué soluciones lograron reducir ese tiempo a aproximadamente dos minutos.

2078 palabras

Cuando las compilaciones del frontend se vuelven lentas, la reacción instintiva es culpar al bundler y comenzar a planificar una migración. Esa reacción suele ser incorrecta. Este estudio de caso trata sobre una plataforma de microfrontend cuyas compilaciones con Webpack superaban los 20 minutos; el análisis mostró que el bundler en sí no era el problema, y una serie de cambios específicos redujeron el tiempo de compilación a aproximadamente 2 minutos sin necesidad de reemplazar a Webpack. Verá cómo identificar el verdadero cuello de botella, qué herramientas basadas en Rust reemplazaron qué etapas del proceso y por qué la instalación de dependencias, los tokens del registro y el uso de capas Docker eran tan importantes como cualquier otro cargador.

Cuando el tiempo de compilación se convierte en un problema para la plataforma

La plataforma en cuestión recibió más de 600 solicitudes de integración al mes por parte de más de diez ingenieros, lo que se tradujo en más de 5,000 ejecuciones de CI. Con 20 minutos por compilación, eso suma más de 1,600 horas mensuales de tiempo de CI. A este volumen, las compilaciones lentas dejan de ser un fastidio y se convierten en un cuello de botella para toda la organización: los comentarios llegan tarde, las colas de CI se acumulan y las correcciones urgentes para producción esperan en fila.

La estructura del monorepo empeoró las cosas:

20 production apps: React and Next.js applications
7 shared libraries
1 centralized E2E test suite
~3,950 TSX/JSX files and 6,600+ TypeScript source files
~350 reusable component modules
144 npm dependencies (74 production, 70 development)
Workspace: single hoisted monorepo
Team: 10+ engineers, 600+ PRs/month
CI: 5,000+ build runs/month

Veinte aplicaciones que comparten siete bibliotecas en un único espacio de trabajo significa que cualquier cambio en las herramientas afecta todo al mismo tiempo.

Por qué el perfilamiento vino antes que la migración

Intercambiar los herramientas de empaquetado en 20 aplicaciones en producción, 7 bibliotecas compartidas y 144 dependencias es un proyecto de alto riesgo. Una regresión en una sola biblioteca compartida puede afectar a todas las aplicaciones, y volver a verificar todos los resultados de compilación podría llevar semanas sin garantía de éxito. Por lo tanto, el equipo analizó primero todo el proceso completo. Ese análisis reveló costos significativos que no estaban relacionados con las herramientas de empaquetado en sí, sino en la verificación de tipos, la orquestación de pruebas y la resolución de dependencias, aspectos que ninguna nueva herramienta de empaquetado habría mejorado.

La lección general es conocida en cualquier trabajo de optimización de rendimiento: optimizar antes de medir convierte cada cambio en una suposición. La compilación con Webpack pasa por varias fases distintas antes de generar un archivo final:

  • Compilación de módulos
  • Ejecución de cargadores
  • Generación de fragmentos
  • Optimización de recursos
  • Minificación
  • Emitencia de recursos

Sin tiempos para cada fase no se puede determinar cuál merece atención, por lo que no se modificó nada en la configuración del cargador ni del plugin hasta que se dispuso de esos datos.

Encontrar el punto crítico con ProgressPlugin

Webpack incluye por defecto ProgressPlugin, que no requiere instalación adicional. Es necesario incluir Webpack en el archivo de configuración:

const webpack = require('webpack');

y agregar el plugin al array plugins:

plugins: [
  new webpack.ProgressPlugin()
]

Al activar la salida de perfilamiento, una línea llamó inmediatamente la atención:

[webpack] 92% sealing > asset processing
TerserPlugin took 825.31s

Un único plugin consumió más de 13 minutos. En comparación con sus vecinos en la fase de optimización, el desequilibrio es evidente:

copy-webpack-plugin      30ms
WriteIndexHtmlPlugin     22ms
RealContentHashPlugin    58ms
CompressionPlugin        70ms
LicenseWebpackPlugin     1.15s
TerserPlugin             825.31s

Cada otro plugin se completó en milisegundos o, en el caso del plugin de licencias, aproximadamente en un segundo. El bundler no era lento; lo que sí lo era era la minificación de JavaScript con Terser. Esa única observación redirigió todo el esfuerzo.

Tiempos a nivel de cargador con Speed Measure Plugin

ProgressPlugin muestra qué etapa es lenta, pero no qué cadena de cargadores dentro de la compilación es la responsable. Para ello, el equipo añadió speed-measure-webpack-plugin, que envuelve la configuración exportada:

const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');
const smp = new SpeedMeasurePlugin();
module.exports = smp.wrap(config);

SMP indica cuánto tiempo tarda cada cadena de cargadores en procesar los módulos, lo que permitió comparar el flujo de trabajo antes y después de introducir SWC. También ofreció una evaluación honesta de la situación real. La cadena CSS formada por mini-css-extract-plugin, css-loader y postcss-loader no se aceleró; su tiempo aumentó ligeramente de 8.66 a 9.36 segundos. El CSS y los módulos que no eran procesados por ningún cargador representaron casi 16 de los 20.78 segundos restantes del tiempo total de procesamiento. En lugar de discutir preferencias entre herramientas, el equipo contó con cifras concretas que indicaban dónde debía realizarse el siguiente ajuste.

Una advertencia importante: SMP envuelve los plugins y cargadores para medir su tiempo de ejecución, y se sabe que puede generar conflictos con algunos plugins más recientes; por lo tanto, úselo como herramienta de diagnóstico y no lo incluya en la configuración de producción.

Objetivos que guiaron todas las decisiones

Antes de cambiar nada, el equipo anotó los objetivos en tres grupos.

Rendimiento de compilación

  • Menor latencia en las compilaciones desde cero.
  • Compilaciones incrementales más rápidas.
  • Mejor uso de los núcleos de CPU disponibles.

Eficiencia del CI

  • Más reutilización de las capas de caché de Docker.
  • Instalación de dependencias más rápida.
  • Pipelines más deterministas.

Estabilidad de la plataforma

  • Mantener la estabilidad de las herramientas a escala empresarial.
  • Evitar migraciones arriesgadas.
  • Mantenerse compatible con el ecosistema existente.
  • Evaluar la velocidad bruta frente a la mantenibilidad a largo plazo.

Por qué se dejaron de lado Vite y Rspack

Vite fue evaluado detenidamente. Es un software excelente y, con frecuencia, la opción adecuada para proyectos nuevos o más simples. La diferencia clave radicó en que se comparó con este pipeline real en lugar de las pequeñas demostraciones que se encuentran en muchas guías de migración. La conclusión fue que el costo más importante, la minificación, es independiente del herramienta de empaquetado: pasar a Vite habría llevado unas cuatro semanas y habría dejado al equipo enfrentándose al mismo cuello de botella, además de tener que resolver incompatibilidades con un nuevo formato de configuración y plugins. El registro de decisiones indicó que Vite era algo a reconsiderar si la compilación llegara a convertirse en el principal cuello de botella.

Una matización importante: Vite minifica JavaScript por defecto con esbuild y no con Terser, por lo que una migración habría cambiado también el minificador en la práctica. Eso refuerza el punto real en lugar de debilitarlo. El factor decisivo era la elección del minificador, y Webpack puede utilizar el mismo minificador rápido sin necesidad de migrar.

También se consideró Rspack, un bundler basado en Rust con una configuración compatible con Webpack, y parecía prometedor. En ese momento, los recientes incidentes de seguridad y un ecosistema aún en desarrollo hicieron que el equipo fuera cauteloso, por lo que se mantuvo en la lista de monitoreo para una reevaluación posterior. Verifique su estado actual antes de sacar sus propias conclusiones.

Volver a construir las etapas más lentas

Una vez establecida la estrategia, el trabajo se centró en reemplazar los componentes más lentos uno por uno.

SWC en lugar de Babel para la transpilación

La transpilación de JavaScript y TypeScript era uno de los costos más significativos que quedaban. Babel fue reemplazado por SWC, un compilador basado en Rust diseñado para transformaciones de JS y TS de alto rendimiento, integrado a través de swc-loader y precedido por thread-loader:

{
  test: /\.(jsx?|tsx?)$/,
  use: [
    'thread-loader',
    {
      loader: 'swc-loader'
    }
  ]
}

Los benchmarks públicos muestran que SWC realiza la transpilación mucho más rápido que Babel, y en esta cadena de procesamiento el cambio permitió una transpilación más rápida, trabajo en paralelo mediante thread-loader, menos bloqueo del proceso principal y ejecuciones de CI más cortas. El punto de entrada swc-loader aquí depende de un archivo .swcrc u opciones incrustadas para la configuración del analizador y JSX; sin ellas, la sintaxis de TypeScript y JSX no se podrá analizar.

esbuild en lugar de Terser para la minificación

El perfil ya había identificado al principal culpable, por lo que la minificación de JavaScript pasó a ser realizada por esbuild a través del plugin esbuild-loader:

new EsbuildPlugin({
  target: 'es2015',
  minify: true
})

La elección se basó en el proyecto minification-benchmarks, que compara a esbuild, terser, swc, uglify-js y otros en cuanto al tamaño después de la minificación, el tamaño comprimido con gzip y el tiempo necesario. Según esos datos:

  • Los resultados de esbuild después de minificar y comprimir eran competitivos; eran un poco mayores que el mejor resultado, pero estaban dentro de un rango del 5 al 8 por ciento;
  • esbuild tardó aproximadamente 295 ms, mientras que terser necesitó unos 6.7 segundos con el mismo archivo de entrada; esta diferencia se amplía al trabajar en un monorepo;
  • @swc/core y oxc-minify generaron resultados de menor tamaño, pero sus desventajas en velocidad e integración hicieron que la decisión se inclinara por esbuild.

La configuración target: 'es2015' indica a esbuild qué sintaxis puede utilizar; asegúrese de que coincida con la compatibilidad real de su navegador, ya que un objetivo más reciente le permite generar código más corto.

LightningCSS para la minificación de CSS

La minificación de CSS ahora se realiza con LightningCSS, desarrollado por el equipo de Parcel, y se integra en css-minimizer-webpack-plugin como su función de minificación:

new CssMinimizerPlugin({
  minify: CssMinimizerPlugin.lightningCssMinify,
})

Los pruebas de rendimiento de LightningCSS lo comparan con los minificadores CSS de cssnano y esbuild. Muestran que es el más rápido en cada escenario probado, con un tamaño de salida consistentemente menor y ganancias especialmente significativas en hojas de estilo grandes como las generadas por Tailwind. En un pipeline CI donde la optimización del CSS afecta directamente a la latencia total, una mejor compresión combinada con menos tiempo de procesamiento lo convirtió en la opción clara. Con ambos cambios en los minificadores, la minificación de JavaScript se volvió aproximadamente 10 veces más rápida y la optimización del CSS unas 6 veces más rápida.

Uso de todos los núcleos del CPU

Los agentes CI suelen tener varios núcleos, pero muchos pipelines dejan la mayor parte de ellos inactivos. Al agregar thread-loader antes de los cargadores costosos, esa tarea se distribuye entre varios procesos:

use: ['thread-loader', 'swc-loader']

Eso redujo los cuellos de botella en las compilaciones lentas, mejoró el uso de recursos y aumentó la eficiencia del CI. Tenga en cuenta que cada trabajador implica un costo adicional por su inicio y por el intercambio de mensajes; en un cargador ya rápido como SWC en un proyecto pequeño, los trabajadores pueden costar más de lo que ahorran, por lo que es necesario medir con y sin ellos.

Instalaciones más rápidas y deterministas con pnpm

La compilación no era el único costo; la instalación de dependencias también consumía minutos en el CI. El equipo comparó npm, yarn, pnpm y bun utilizando pruebas públicas que evaluaban la velocidad de instalación, el uso del disco y los modelos de resolución. Bun mostró buenos resultados en pruebas sintéticas, pero pnpm se destacó por la madurez de su ecosistema, la compatibilidad con Node.js y su experiencia en grandes sistemas en producción.

Mientras que npm copia los paquetes en cada node_modules, pnpm mantiene un almacén global con direcciones de contenido: cada versión del paquete se descarga una sola vez y se enlaza de forma permanente a los proyectos. Esto conlleva varias ventajas:

  • Las instalaciones son más rápidas porque los paquetes se obtienen una sola vez y se reutilizan;
  • Se reduce el uso del disco, ya que los espacios de trabajo ya no duplican los mismos paquetes;
  • La resolución estricta evita las dependencias fantasma, es decir, aquellos paquetes que tu código importa sin declararlos, lo que hace que las compilaciones sean más deterministas;
  • Mejora el caché de capas de Docker, ya que se puede reutilizar el almacén entre compilaciones.

En Docker, un montaje de caché de BuildKit mantiene el almacén de pnpm entre compilaciones, mientras que --frozen-lockfile hace que la instalación falle si el archivo de bloqueo está desactualizado:

RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile

Un detalle de autenticación que rompió el caché

Una de las soluciones más efectivas no tuvo nada que ver con las herramientas del frontend. Los paquetes provenían de AWS CodeArtifact, cuyos tokens de autorización expiran después de 12 horas. La obtención repetida de tokens dentro del pipeline causaba la invalidación del caché, trabajos de autenticación repetidos y problemas en las capas de Docker, ya que un valor cambiante introducido en una capa modificaba la clave de caché de dicha capa.

La solución fue solicitar el token una sola vez por tarea de Jenkins y reutilizarlo en cada paso:

export CODEARTIFACT_AUTH_TOKEN=$(aws codeartifact get-authorization-token \
  --domain <domain> \
  --domain-owner <owner> \
  --query authorizationToken \
  --output text)

Eso permitió una mejor reutilización de capas, menos instalaciones redundantes y pipelines más estables. Si pasa un token de este tipo a una compilación de Docker, prefiera un montaje secreto en lugar de un argumento de compilación para que no quede registrado en el historial de la imagen ni invalide el caché.

Organización de las capas en los Dockerfiles para acceder al caché

Finalmente, los Dockerfiles se reorganizaron en capas ordenadas de la que cambia con menos frecuencia a la que cambia con más frecuencia:

  1. el tiempo de ejecución básico;
  2. la instalación de dependencias;
  3. la copia del código fuente;
  4. el proceso de compilación con Webpack.

Al combinarlo con --mount=type=cache para el almacén de paquetes y --mount=type=secret para las credenciales, este orden permitió que los cambios en el código solo reconstruyeran las dos últimas capas, aumentando significativamente la reutilización de la caché.

Puntos clave

  • Trate la velocidad de compilación como un problema de sistemas. Aquí los mayores beneficios provinieron del minificador, las instalaciones, las credenciales y la estructura en capas de Docker, no del bundler.
  • Realice un perfilamiento primero. ProgressPlugin redujo a minutos lo que antes llevaba 13 minutos con Terser; una migración especulativa habría tardado semanas.
  • Las herramientas basadas en Rust como SWC, esbuild y LightningCSS tienen un efecto compuesto: cada una elimina una parte distinta de la latencia.
  • pnpm y un caché disciplinado de Docker mejoran el determinismo además de la velocidad.
  • Cualquier elemento que cambie en cada ejecución, incluso un token de autenticación, puede destruir silenciosamente el caché.
  • Mantenga las migraciones en consideración, pero condicionadas por los datos. Al modernizar las etapas individuales, esta plataforma pasó de más de 20 minutos a unos 2, manteniendo intacto su ecosistema.
  • Lecturas relacionadas