DEV Community

Gustavo Ramirez
Gustavo Ramirez

Posted on

Bundle splitting guiado por métricas reales de CloudWatch RUM

Bundle splitting es fácil. Lo difícil es saber qué splitear y qué no. Muchos equipos splitean basándose en intuición y terminan con más overhead que ganancia: 200 chunks chicos que tardan más que 3 medianos por el costo del HTTP overhead. Durante una migración de un portal bancario descubrí una estrategia mejor: dejar que CloudWatch RUM me diga qué rutas realmente importan y splitear por ahí.

Por qué intuición no basta

El patrón típico es dividir por ruta:

const Dashboard = lazy(() => import('./pages/Dashboard'));
const Reports = lazy(() => import('./pages/Reports'));
const Settings = lazy(() => import('./pages/Settings'));
Enter fullscreen mode Exit fullscreen mode

Parece razonable. Pero si el 95% de los usuarios solo ven Dashboard y Reports en la misma sesión, estás pagando el costo de dos chunks cuando uno solo habría sido mejor. Por otro lado, Settings lo ven 5 usuarios al mes. Descargarlo por defecto es desperdicio.

Necesitaba datos reales sobre qué rutas visitan los usuarios, en qué orden y con qué frecuencia. CloudWatch RUM los tiene.

Instrumentación personalizada de RUM

RUM captura page views automáticamente. Pero yo necesitaba medir también qué chunks se cargaban en cada visita:

// src/lib/rum.ts
import { AwsRum, AwsRumConfig } from 'aws-rum-web';

const config: AwsRumConfig = {
  sessionSampleRate: 1,
  guestRoleArn: process.env.VITE_RUM_GUEST_ROLE_ARN!,
  identityPoolId: process.env.VITE_RUM_IDENTITY_POOL!,
  endpoint: 'https://dataplane.rum.us-east-1.amazonaws.com',
  telemetries: ['performance', 'errors', 'http'],
  allowCookies: true,
  enableXRay: false,
};

export const rum = new AwsRum(
  process.env.VITE_RUM_APP_MONITOR_ID!,
  '1.0.0',
  'us-east-1',
  config
);

const originalImport = (window as any).__dynamicImport;
(window as any).__dynamicImport = async (chunkName: string, importFn: () => Promise<any>) => {
  const start = performance.now();
  try {
    const module = await importFn();
    const duration = performance.now() - start;

    rum.recordEvent('chunk_loaded', {
      chunkName,
      duration,
      route: window.location.pathname,
    });

    return module;
  } catch (err) {
    rum.recordEvent('chunk_error', {
      chunkName,
      error: String(err),
      route: window.location.pathname,
    });
    throw err;
  }
};
Enter fullscreen mode Exit fullscreen mode

Después uso este wrapper en los lazy:

// src/routes.tsx
import { lazy } from 'react';

const wrap = (name: string, fn: () => Promise<any>) => lazy(() => {
  return (window as any).__dynamicImport(name, fn);
});

export const Dashboard = wrap('dashboard', () => import('./pages/Dashboard'));
export const Reports = wrap('reports', () => import('./pages/Reports'));
export const Settings = wrap('settings', () => import('./pages/Settings'));
export const UserProfile = wrap('user-profile', () => import('./pages/UserProfile'));
export const AdminPanel = wrap('admin', () => import('./pages/AdminPanel'));
Enter fullscreen mode Exit fullscreen mode

Exportando eventos a CloudWatch Logs Insights

CloudWatch RUM pone eventos custom en CloudWatch Logs automáticamente (en un log group específico del app monitor). Desde ahí puedo correr Logs Insights:

fields @timestamp, event_details.chunkName as chunk, event_details.route as route
| filter event_type = "chunk_loaded"
| stats count(*) as loads by chunk
| sort loads desc
Enter fullscreen mode Exit fullscreen mode

Salida real de un mes de mi aplicación:

chunk           | loads
----------------|--------
dashboard       | 48210
reports         | 32104
user-profile    | 8421
settings        | 1203
admin           | 145
integrations    | 89
help-center     | 42
onboarding      | 12
Enter fullscreen mode Exit fullscreen mode

Dashboard y Reports son el 86% del tráfico. Juntarlos en un solo chunk tendría sentido si sus dependencias se superponen mucho.

Luego correlaciono esto con el tamaño de cada chunk:

// scripts/analyze-bundles.ts
import fs from 'node:fs';
import path from 'node:path';

interface Stats {
  assets: Array<{
    name: string;
    size: number;
    chunks: string[];
  }>;
  chunks: Array<{
    id: string;
    names: string[];
    files: string[];
  }>;
}

const stats: Stats = JSON.parse(
  fs.readFileSync(path.join('dist', 'stats.json'), 'utf-8')
);

const chunkSizes = new Map<string, number>();
for (const chunk of stats.chunks) {
  const name = chunk.names[0] || chunk.id;
  const totalSize = chunk.files
    .map((f) => stats.assets.find((a) => a.name === f)?.size ?? 0)
    .reduce((a, b) => a + b, 0);
  chunkSizes.set(name, totalSize);
}

const rumCounts = new Map<string, number>([
  ['dashboard', 48210],
  ['reports', 32104],
  ['user-profile', 8421],
  ['settings', 1203],
  ['admin', 145],
  ['integrations', 89],
  ['help-center', 42],
  ['onboarding', 12],
]);

console.log('chunk\tsize_kb\tloads/month\ttotal_mb_transferred');
for (const [name, size] of chunkSizes) {
  const loads = rumCounts.get(name) ?? 0;
  const totalMb = (size * loads) / (1024 * 1024);
  console.log(
    `${name}\t${(size / 1024).toFixed(1)}\t${loads}\t${totalMb.toFixed(1)}`
  );
}
Enter fullscreen mode Exit fullscreen mode

Resultado:

chunk           size_kb   loads     transferred_mb
dashboard       340       48210     16030
reports         220       32104     6910
user-profile    85        8421      705
settings        45        1203      53
admin           180       145       26
integrations    95        89        8
help-center     30        42        1
onboarding      250       12        3
Enter fullscreen mode Exit fullscreen mode

Descubrimientos accionables:

  1. Onboarding pesa 250KB y se descarga 12 veces al mes. Total transferido: 3MB. Pero esos 12 usuarios nuevos están bajando una página pesada. Optimización: dividir onboarding en pasos lazy.

  2. Admin pesa 180KB para 145 usuarios/mes. Ese chunk probablemente está en webpack chunks vendor con mucho código compartido. No tocar.

  3. Dashboard + Reports juntos suman ~22GB transferidos al mes. Si sus dependencias se solapan, combinarlos ahorraría tráfico real.

Analizando overlap de dependencias

npx webpack-bundle-analyzer dist/stats.json
Enter fullscreen mode Exit fullscreen mode

Visualmente se ven los treemaps. Mejor aún, automatizo:

// scripts/chunk-overlap.ts
import fs from 'node:fs';

interface Stats {
  modules: Array<{
    name: string;
    size: number;
    chunks: string[];
  }>;
}

const stats: Stats = JSON.parse(fs.readFileSync('dist/stats.json', 'utf-8'));

function overlap(chunkA: string, chunkB: string) {
  const modsA = new Set<string>();
  const modsB = new Set<string>();
  let sharedSize = 0;

  for (const mod of stats.modules) {
    if (mod.chunks.includes(chunkA)) modsA.add(mod.name);
    if (mod.chunks.includes(chunkB)) modsB.add(mod.name);
  }

  for (const mod of stats.modules) {
    if (mod.chunks.includes(chunkA) && mod.chunks.includes(chunkB)) {
      sharedSize += mod.size;
    }
  }

  return {
    sharedModules: [...modsA].filter((m) => modsB.has(m)).length,
    sharedSize,
  };
}

console.log('Dashboard + Reports:', overlap('dashboard', 'reports'));
console.log('Dashboard + Settings:', overlap('dashboard', 'settings'));
Enter fullscreen mode Exit fullscreen mode

Salida:

Dashboard + Reports: { sharedModules: 42, sharedSize: 85340 }
Dashboard + Settings: { sharedModules: 8, sharedSize: 4120 }
Enter fullscreen mode Exit fullscreen mode

Dashboard y Reports comparten 85KB. Si los uniera en un solo chunk de 475KB (340 + 220 - 85), muchos usuarios se ahorrarían una segunda request. Para los que solo ven Dashboard, pagan 135KB extra. Decisión de negocio: ¿cuántos ven solo Dashboard y nunca Reports?

Vuelvo a Logs Insights:

fields @timestamp, event_details.route as route, session_id
| filter event_type = "chunk_loaded"
| stats
    count_distinct(session_id) as sessions,
    count(distinct event_details.chunkName) as chunks_used
  by session_id
Enter fullscreen mode Exit fullscreen mode

El 78% de sesiones carga tanto Dashboard como Reports. Combinar los chunks sería correcto.

La optimización con Webpack

// webpack.config.js (fragmento)
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        coreFlow: {
          name: 'core-flow',
          test: /[\\/]src[\\/]pages[\\/](Dashboard|Reports)[\\/]/,
          priority: 20,
          minChunks: 1,
        },
        adminBundle: {
          name: 'admin-bundle',
          test: /[\\/]src[\\/]pages[\\/](AdminPanel|Integrations)[\\/]/,
          priority: 15,
          minChunks: 1,
        },
        vendor: {
          name: 'vendor',
          test: /[\\/]node_modules[\\/]/,
          priority: 10,
          reuseExistingChunk: true,
        },
      },
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

En Vite el approach es parecido:

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: {
    rollupOptions: {
      output: {
        manualChunks: (id) => {
          if (id.includes('/pages/Dashboard') || id.includes('/pages/Reports')) {
            return 'core-flow';
          }
          if (id.includes('/pages/AdminPanel') || id.includes('/pages/Integrations')) {
            return 'admin-bundle';
          }
          if (id.includes('node_modules')) {
            return 'vendor';
          }
        },
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Prefetch basado en patrones RUM

Otro insight de los datos: después de Dashboard, el 60% de usuarios va a Reports en menos de 90 segundos. Entonces prefetcheo Reports en idle time:

// src/hooks/useRoutePrefetch.ts
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';

const PREFETCH_RULES: Record<string, string[]> = {
  '/dashboard': ['/reports'],
  '/reports': ['/reports/detail'],
  '/user-profile': ['/settings'],
};

const prefetchChunk = (route: string) => {
  switch (route) {
    case '/reports':
      return import('./pages/Reports');
    case '/reports/detail':
      return import('./pages/ReportsDetail');
    case '/settings':
      return import('./pages/Settings');
    default:
      return null;
  }
};

export function useRoutePrefetch() {
  const location = useLocation();

  useEffect(() => {
    const candidates = PREFETCH_RULES[location.pathname];
    if (!candidates) return;

    if ('requestIdleCallback' in window) {
      const id = requestIdleCallback(() => {
        for (const route of candidates) {
          prefetchChunk(route);
        }
      });
      return () => cancelIdleCallback(id);
    } else {
      const timeout = setTimeout(() => {
        for (const route of candidates) {
          prefetchChunk(route);
        }
      }, 2000);
      return () => clearTimeout(timeout);
    }
  }, [location.pathname]);
}
Enter fullscreen mode Exit fullscreen mode

Resultado: cuando el usuario navega a Reports, el chunk ya está en memoria. Transición instantánea sin spinner.

Benchmark de la optimización

Antes:

Initial load:
  main.js         180 KB
  vendor.js       420 KB
  Total first paint: 600 KB

Navigation Dashboard -> Reports:
  dashboard.js    340 KB (cached)
  reports.js      220 KB (new download)
  Spinner visible: ~400ms
Enter fullscreen mode Exit fullscreen mode

Después:

Initial load:
  main.js         180 KB
  vendor.js       385 KB (admin splits moved)
  Total first paint: 565 KB

Navigation Dashboard -> Reports:
  core-flow.js    475 KB (cached, incluye ambos)
  Spinner visible: 0ms
Enter fullscreen mode Exit fullscreen mode

Mejora:

Métrica Antes Después Cambio
First Contentful Paint 1.8s 1.7s -5%
Time to Interactive 3.2s 2.9s -9%
Route transition (D→R) 400ms 0ms -100%
Bytes totales/sesión media 1.2 MB 850 KB -30%

El -30% en bytes totales viene de que muchos usuarios ya no descargan admin, onboarding y settings por defecto.

Lo que aprendí

1. CloudWatch RUM tiene tier gratuito generoso.
Los primeros 100K eventos al mes son gratis. Para una app con 50K sesiones al mes, cabía sin costo. Pasado ese umbral, 1 USD por 100K eventos.

2. Sampling rate 100% no siempre es correcto.
Usé sessionSampleRate: 1 al inicio. Para 200K sesiones/mes, generaba 10M eventos. Me pasé al tier pago. Bajé a 0.1 (10%) para el día a día y subo a 1 cuando debug performance issues puntuales.

3. Los eventos custom tienen schema rígido.
RUM guarda eventos custom en event_details como JSON pero con límite de campos y tipos. Probé pasar arrays, objetos anidados: se truncaban. Keep it flat: strings y números simples.

4. Vite no emite stats.json por defecto.
Necesitaba stats para análisis. Plugin: rollup-plugin-visualizer con emitFile: true, filename: 'stats.json', template: 'raw-data'. Diferente al webpack-bundle-analyzer pero parseable.

5. Prefetch agresivo consume datos móviles.
Usuarios de planes limitados se quejaron del uso de datos. Agregué check: navigator.connection?.saveData === true desactiva prefetch, y si el effectiveType es '2g' o 'slow-2g' tampoco prefetcheo.

Cuándo NO splitear

Si tu bundle total es menor a 200KB, no splitees. El overhead HTTP de chunks pequeños te cuesta más que el beneficio.

Si tus usuarios son internos y siempre están en red corporativa con latencia baja, el beneficio es marginal. Carga toda la app y dejá que el cache del browser haga el resto.

Si no tenés métricas reales de uso, no improvises. Splitear basándose en "parece que esta ruta es rara" termina en decisiones equivocadas. Instrumentá primero.


El próximo artículo cubre internacionalización con Next.js y DynamoDB para traducciones editables por equipo no técnico. Spoiler: reemplazamos Crowdin con 100 líneas de código.

Top comments (0)