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'));
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;
}
};
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'));
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
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
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)}`
);
}
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
Descubrimientos accionables:
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.
Admin pesa 180KB para 145 usuarios/mes. Ese chunk probablemente está en webpack chunks
vendorcon mucho código compartido. No tocar.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
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'));
Salida:
Dashboard + Reports: { sharedModules: 42, sharedSize: 85340 }
Dashboard + Settings: { sharedModules: 8, sharedSize: 4120 }
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
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,
},
},
},
},
};
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';
}
},
},
},
},
});
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]);
}
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
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
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)