wandres.dev
TREE SHAKING · eliminar código muerto

Qué es el tree shaking: eliminar el código muerto del bundle

El tree shaking es la variante moderna de una vieja optimización de compiladores —la eliminación de código muerto, o DCE— llevada al grafo de módulos: quitar del bundle final todo aquello que no se importa ni se usa desde los puntos de entrada. Por qué Rollup lo replanteó como inclusión de código vivo en vez de exclusión de código muerto, en qué se diferencia de la poda de sentencias que hace el minificador, y por qué esta idea gobierna el tamaño real de todo lo que envías.

⏱ 14 min

Escribes una librería con cuarenta funciones y usas tres. ¿Por qué habría de viajar el resto hasta el navegador de tu usuario? El tree shaking es la respuesta del bundler a esa pregunta: un análisis que recorre tu grafo de módulos desde los puntos de entrada, marca lo que de verdad se alcanza y descarta todo lo demás. El nombre es una metáfora —sacudes el árbol y las hojas muertas caen— pero por debajo hay una idea de compiladores con décadas de historia, la eliminación de código muerto, reformulada para el mundo de los módulos. Interiorizarla es entender por qué importar bien pesa más que escribir poco.

🎯 Al terminar esta lección sabrás
  • Definir la eliminación de código muerto (DCE) y situar el tree shaking como su forma sobre el grafo de módulos.
  • Distinguir el enfoque de inclusión de código vivo del de exclusión de código muerto.
  • Separar la poda de módulos que hace el bundler de la poda de sentencias que hace el minificador.
  • Comprender por qué el tree shaking solo ocurre en el build de producción y gobierna el tamaño real del envío.

De la eliminación de código muerto al tree shaking

La eliminación de código muerto —dead code elimination, o DCE— es una optimización clásica de compiladores: quitar todo aquel código que no puede afectar al comportamiento observable del programa. La sentencia inalcanzable tras un return, la variable que calculas y nunca lees, la rama de un if cuya condición es una constante falsa. Los compiladores llevan décadas haciendo esto dentro de una función, sobre el flujo de control local.

La metáfora del título merece tomarse en serio, porque captura bien el mecanismo. Imagina tu aplicación como un árbol cuyas raíces son los puntos de entrada y cuyas hojas son los exports: al sacudirlo, las hojas que una rama viva sujeta con firmeza permanecen, y las que ya estaban muertas —las que ninguna rama conecta con la raíz— caen. El bundler no recorre hoja por hoja decidiendo cuál cortar; agarra el tronco, sacude, y deja que la propia estructura de conexiones separe lo vivo de lo muerto. Esa es la intuición exacta: no una tijera que poda con criterio, sino una gravedad que descarta todo lo que no está sostenido.

El tree shaking es ese mismo principio elevado al nivel del grafo de módulos. En vez de preguntar “¿es alcanzable esta sentencia dentro de esta función?”, pregunta “¿se alcanza este export desde algún punto de entrada de la aplicación?”. La unidad de análisis ya no es la sentencia, sino el binding exportado: una función, una clase, una constante que un módulo pone a disposición de otros. El término lo popularizó Rich Harris con Rollup alrededor de 2015, justo cuando los módulos ESM hicieron viable el análisis.

Ese cambio de unidad no es un detalle académico. Un compilador tradicional podía eliminar una función privada sin usar dentro de un archivo, pero no tenía forma de saber qué archivos de una aplicación entera se necesitaban unos a otros, porque el concepto de módulo con dependencias explícitas todavía no formaba parte del lenguaje. El tree shaking pudo existir precisamente cuando el lenguaje ganó un sistema de módulos con esa información escrita de forma legible para las herramientas. No fue un algoritmo nuevo lo que lo habilitó, sino una estructura nueva en el código sobre la que aplicar un algoritmo viejo.

// math.js
export function suma(a, b) { return a + b }
export function resta(a, b) { return a - b }   // nadie la importa

// main.js
import { suma } from './math.js'
console.log(suma(2, 3))
// tras el tree shaking, resta nunca entra en el bundle final

El salto de escala es lo esencial. La DCE clásica razona sobre lo que ocurre dentro de una frontera —una función, un bloque—; el tree shaking razona sobre las fronteras mismas, las aristas del grafo por las que un módulo alcanza a otro. Ambos comparten la definición de muerto —código que no puede alterar el resultado observable— pero la aplican a escalas distintas, y por eso se complementan en lugar de competir. Uno limpia el interior de las funciones; el otro decide qué funciones existen siquiera.

Conviene tener presentes las formas clásicas de código muerto, porque son las que el minificador seguirá cazando dentro de lo que el tree shaking deje pasar:

  • Código inalcanzable: todo lo que sigue a un return, un throw o un break incondicional.
  • Variables muertas: un valor que se calcula y se asigna pero que nadie llega a leer jamás.
  • Ramas constantes: un if (false) o un if (true) cuyo desenlace se conoce ya al compilar.
  • Propagación: una constante que, una vez sustituida, vuelve muerta a otra rama entera.

Que ambas técnicas compartan la palabra “muerto” es fuente de una confusión persistente. Cuando alguien dice que “el bundler elimina el código muerto”, conviene precisar de qué capa habla: la del grafo, que decide módulos y exports, o la de la sentencia, que decide instrucciones dentro de una función. Son dos análisis distintos, con dos alcances distintos, encadenados en el mismo pipeline.

En lo que sigue, cuando digamos tree shaking nos referiremos a la capa del grafo —la que Rollup, Rolldown o esbuild aplican sobre módulos y exports—, y reservaremos la DCE de sentencias para el trabajo complementario del minificador. Mantener esa frontera nítida es ya la mitad de entender por qué a veces algo se poda y a veces se queda.

Inclusión de código vivo, no exclusión de código muerto

La aportación conceptual de Rollup no fue la poda en sí, sino invertir su dirección. El enfoque ingenuo es exclusión: incluye todo el código y luego intenta detectar y recortar las partes muertas. Rollup lo dio la vuelta y lo planteó como inclusión de código vivo: parte de la nada e incluye únicamente lo que se alcanza desde los puntos de entrada. El código no se elimina; sencillamente, nunca llega a añadirse.

El matiz de vocabulario esconde un cambio de mentalidad. Hablar de eliminación invita a imaginar un bundle completo del que se recorta; hablar de inclusión invita a imaginar un bundle vacío que se rellena a partir de lo que se usa. La segunda imagen es la fiel: el resultado se construye siguiendo las aristas del grafo desde las entradas, y en ningún momento contiene un módulo al que no se haya llegado por ese recorrido. No hay una fase de borrado; hay una ausencia de copiado.

Esta inversión no es cosmética. Cuando el valor por defecto es la exclusión, cada trozo de código tiene que ganarse su sitio en el bundle demostrando que es alcanzable. La reachability se calcula recorriendo el grafo: desde cada entrada, se sigue cada import, se marcan los exports que de verdad se usan, y se repite recursivamente hasta agotar el alcance. Lo que queda sin marcar no es que se borre —es que jamás se copió al resultado.

El recorrido es un cálculo de punto fijo: marcar un export puede revelar nuevos imports, que a su vez marcan más exports, y así hasta que una pasada completa no añade nada nuevo. Ese carácter transitivo es lo que vuelve al análisis a la vez potente y frágil. Potente, porque persigue el uso a través de cuantas capas de reexport haga falta, por profundas que sean. Frágil, porque basta un solo eslabón opaco —un módulo que el bundler no sabe analizar— para que, por prudencia, dé por alcanzado todo lo que cuelga de él, y una rama muerta viaje entera al bundle solo por colgar del sitio equivocado.

Esta reformulación se paga en tranquilidad: un sistema que incluye por alcance es seguro por defecto. En un enfoque de exclusión, un error del análisis borra algo vivo y rompe el programa; en uno de inclusión, un error del análisis conserva algo muerto y, como mucho, malgasta unos bytes. Rollup eligió deliberadamente que el modo de fallo fuera el desperdicio y no la rotura, y esa asimetría es la razón de que puedas confiar en la poda sin auditar cada eliminación. Prefieres un bundler tímido que a veces deja código de más antes que uno audaz que a veces borra lo que hacía falta.

ℹ️
Un binding, no un archivo

La granularidad importa. El tree shaking no razona sobre archivos enteros de entrada, sino sobre bindings exportados individuales. De un módulo con veinte exports, un build bien afinado puede quedarse con dos y descartar dieciocho, aunque los veinte vivan en el mismo archivo fuente. Esa finura es la que hace que importar una función suelta de una librería moderna no arrastre a sus otras treinta y nueve compañeras.

💡
Importa nombres, no paquetes enteros

La forma en que importas decide cuánto puede sacudir el bundler. Un import { debounce } from 'libreria' le da un blanco preciso: ese binding y su cierre transitivo. Un import libreria from 'libreria' seguido de libreria.debounce le entrega el objeto entero y le complica probar qué sobra. La regla práctica es preferir los named imports y no traerte el default de una utilidad cuando solo quieres una función. No es una cuestión de estilo: es la diferencia entre un árbol que se sacude limpio y uno que se queda con todas sus hojas colgando.

Dos capas de poda: el bundler y el minificador

Aquí se confunde a menudo a dos actores distintos. El bundler —Rollup, Rolldown, o esbuild actuando como tal— hace el tree shaking a nivel de módulo y de export: decide qué bindings y qué módulos entran en el grafo. El minificador —terser, esbuild, el minificador de Oxc— hace DCE a nivel de sentencia y de expresión dentro de lo que sobrevive: elimina una rama if (false), una variable local no leída, el código inalcanzable tras un return.

Las dos capas se componen. El bundler sacude el árbol a la altura de las ramas —módulos y exports enteros— y el minificador poda las ramitas que quedan dentro de cada función. Un bundle está de verdad optimizado solo cuando ambos han corrido, y por eso el tree shaking es un fenómeno del build de producción: en desarrollo, Vite sirve ESM sin empaquetar para arrancar al instante, así que no hay ni grafo cerrado que sacudir ni minificador que pase. Medir el efecto en vite dev es medir en el sitio equivocado.

Un ejemplo mínimo hace tangible el reparto de tareas entre ambas capas:

// Lo que poda el BUNDLER: el modulo entero.
// Si formato.js no se importa desde ninguna entrada, no existe en el bundle,
// por muchas funciones utiles que contenga.

// Lo que poda el MINIFICADOR: el interior de lo que sobrevivio.
function saludar(nombre) {
  const sinUsar = calcular()    // variable muerta -> fuera
  if (false) { registrar() }    // rama constante -> fuera
  return 'Hola ' + nombre       // lo unico que queda
}

Ninguna de las dos capas podría hacer el trabajo de la otra. El minificador, que mira una función cada vez, carece de la visión global del grafo para saber que un módulo entero es inalcanzable; el bundler, que razona sobre módulos y exports, no desciende al detalle de una rama muerta dentro de una función concreta. Se necesitan mutuamente, y su suma —no cualquiera de ellas por separado— es lo que produce un artefacto de verdad mínimo.

La composición de las dos capas es jerárquica, no arbitraria: el bundler decide primero qué módulos y qué exports existen en el resultado, y solo entonces el minificador se ensaña con el interior de lo que sobrevivió. Invertir el orden no tendría sentido —minificar código que el bundler va a descartar es trabajo tirado—, y por eso el pipeline los encadena en esa secuencia. Cuando alguien afirma que su bundle “no hace tree shaking”, la pregunta útil es cuál de las dos capas falla: casi siempre es la primera, y la causa vive en cómo se importa o en un sideEffects ausente, no en el minificador, que rara vez es el culpable.

Qué no es el tree shaking, y por qué se confunde

Despejar tres malentendidos frecuentes evita expectativas falsas. El primero: el tree shaking no minifica. No acorta nombres ni comprime espacios; decide inclusión. Son dos etapas distintas que la conversación cotidiana funde en una sola palabra. El segundo: no es code splitting. No parte tu aplicación en trozos que se cargan bajo demanda —esa es otra optimización del mismo bundler—; el tree shaking decide qué entra, el splitting decide en qué archivo acaba. El tercero, el más caro: no es automático en cualquier condición. Depende de que el código sea estáticamente analizable y honesto sobre sus efectos, y esas dos condiciones se rompen con una facilidad que las próximas lecciones documentan.

De los tres, el último es el que más disgustos da. Es tentador creer que basta con activar el modo de producción para que todo lo no usado se evapore, y luego sorprenderse al encontrar media librería incrustada en el bundle. La poda no es un interruptor que se enciende, sino una propiedad que el grafo posee o no según cómo esté construido. Todo el resto de este nivel es, en el fondo, el estudio de qué vuelve podable a un grafo y qué lo estropea.

Fija esta idea porque reordena todo lo que viene: el tree shaking no es una función que ejecutas sobre tu código, sino una consecuencia que tu código permite o impide según cómo esté estructurado. Las tres lecciones siguientes desmenuzan las condiciones de esa permisividad —la estructura estática que hace analizable el grafo, los contratos de efectos que autorizan a podar módulos enteros, y los límites donde el análisis, con razón, se detiene—. La última cierra el círculo enseñándote a verificar que todo ese andamiaje funcionó de verdad sobre tu bundle.

📝
Tree shaking y minificación viajan juntos pero no son lo mismo

En la práctica los verás siempre juntos, porque un build de producción los ejecuta a ambos, y esa cercanía alimenta la confusión. Pero son separables: puedes empaquetar con tree shaking y sin minificar —útil para leer la salida al depurar— y verás módulos enteros ausentes y, aun así, nombres largos y espacios intactos. Esa vista intermedia, empaquetada pero legible, es una de las mejores formas de comprobar con tus propios ojos qué capa hizo qué.

🌳

Tree shaking

Lo hace el bundler, a granularidad de export y de módulo. Decide qué se incluye en el grafo recorriéndolo desde las entradas.

✂️

DCE del minificador

Poda dentro de cada función: ramas muertas, variables no leídas, código tras un return. Trabaja sobre lo que el bundler ya dejó pasar.

🎯

Inclusión, no exclusión

El valor por defecto es fuera. El código entra solo si demuestra ser alcanzable; no se borra lo muerto, no se añade.

📦

Solo en producción

En dev, Vite sirve ESM sin empaquetar y nada se sacude. El tree shaking vive en vite build, con el grafo cerrado.

flowchart TD
E[puntos de entrada] --> G[grafo de modulos]
G --> V[exports alcanzados: codigo vivo]
G --> M[exports no alcanzados: codigo muerto]
V --> B[bundle de produccion]
M --> X[nunca se copia al bundle]
style V fill:#a6e3a1,color:#11111b
style B fill:#a6e3a1,color:#11111b
style M fill:#f38ba8,color:#11111b
style X fill:#f38ba8,color:#11111b
El código que no envías es el que nunca falla, nunca tarda y nunca pesa

Hay una jerarquía de optimización que casi nadie enuncia pero que gobierna en silencio todo el frontend, y el tree shaking es su primer peldaño. La forma más barata de que un fragmento de código no ralentice a tu usuario no es hacerlo rápido: es no enviarlo. Cada kilobyte que no cruza la red no se descarga, no se parsea, no se compila y no se ejecuta; su coste, en todas esas dimensiones a la vez, es exactamente cero. El tree shaking es la maquinaria que convierte esa verdad en un resultado automático, y hacerlo bien reordena tu intuición de dónde está el peso de una aplicación. Deja de pensar en cuánto código escribes y empieza a pensar en cuánto código alcanzas. Una librería de doscientas funciones de la que usas cinco puede costarte lo mismo que un módulo de cinco funciones, si el grafo está limpio y el análisis puede probar que las otras ciento noventa y cinco son inalcanzables. Ahí está el giro mental que separa a quien sufre el tamaño de sus bundles de quien lo diseña: el tamaño no es una función de lo que hay en tu node_modules, sino de lo que tus puntos de entrada tocan a través del grafo. La consecuencia estratégica es profunda. Si el bundler incluye por alcance, entonces la forma en que importas —named imports frente a importar el paquete entero, módulos con o sin efectos declarados, barriles que reexportan medio mundo— determina más el peso final que la cantidad de código que teclees. El tree shaking no premia al que escribe poco; premia al que estructura sus dependencias para que el análisis pueda decir “esto no se usa” con certeza. Y esa certeza, como verás en las próximas lecciones, no es gratis: depende de que los módulos sean estáticamente analizables y honestos sobre sus efectos. Pero el principio rector ya está aquí, y es de los que no se olvidan una vez visto: no optimizas enviando código mejor, optimizas no enviándolo.

⚔️ Sacude tu primer árbol
  1. Crea un módulo con dos exports, importa solo uno desde tu entrada y ejecuta vite build; confirma en el dist que el otro no aparece.
  2. Ahora mide lo mismo en vite dev inspeccionando lo que sirve el navegador y explica por qué el export “muerto” sigue ahí.
  3. Reformula en voz alta la diferencia entre “eliminar código muerto” e “incluir solo código vivo”: ¿cuál es el valor por defecto en cada caso?
  4. Identifica en un build tuyo un caso de poda que hizo el minificador y no el bundler —por ejemplo, una rama if (false)— y razona a qué capa corresponde.
  5. Elige una dependencia que uses a medias y estima cuánto de ella crees que alcanzas de verdad; en la lección 5 aprenderás a comprobarlo.