wandres.dev
TREE SHAKING · eliminar código muerto

Por qué ESM habilita el tree shaking y CommonJS no

El tree shaking no es magia del bundler: es una consecuencia directa de la estructura estática de los módulos ESM. Los import y export son declaraciones fijas, con especificadores literales y bindings inmutables que se conocen antes de ejecutar una sola línea, y esa analizabilidad es lo que permite construir el grafo y decidir qué se usa. CommonJS, con su require dinámico y su module.exports mutable en tiempo de ejecución, no ofrece esa garantía, y por eso resiste la poda. La estructura estática fue un objetivo de diseño explícito de ESM, no un accidente feliz.

⏱ 15 min

El tree shaking parece una proeza del bundler, pero en realidad es una propiedad que el lenguaje de módulos le regala o le niega. ESM se diseñó con una estructura estática: los import y export son declaraciones fijas, con nombres literales, que se pueden leer y enlazar antes de ejecutar nada. Esa analizabilidad de antemano es exactamente lo que hace posible construir el grafo y probar qué se alcanza. CommonJS nació de otra filosofía —require es una función, module.exports es un objeto que mutas cuando quieras— y esa dinámica, tan cómoda para escribir, es la que vuelve indecidible qué se importa y qué se exporta. La poda no es una técnica; es un privilegio de lo estático.

🎯 Al terminar esta lección sabrás
  • Entender qué significa que la estructura de módulos ESM sea estática y analizable antes de ejecutar.
  • Ver por qué los bindings de import son inmutables y de solo lectura, y qué habilita eso.
  • Comprender por qué require y module.exports de CommonJS impiden el análisis fiable.
  • Reconocer cómo la interop CJS y los namespace imports afectan a lo que se puede sacudir.

La estructura estática de ESM

La clave está en una palabra del estándar: los módulos ESM tienen una estructura estática. Los import y export solo pueden aparecer en el nivel superior del módulo, nunca dentro de un if, un bucle o una función. Sus especificadores son cadenas literales —'./math.js', no una variable calculada— y los nombres que importas y exportas son identificadores fijos. Todo ello significa que un analizador puede leer el módulo, sin ejecutarlo, y saber con certeza qué depende de qué.

Fíjate en las dos palabras sin ejecutarlo, porque en ellas está todo. Ejecutar código para averiguar qué hace es lo natural en tiempo de ejecución, pero es inviable —y peligroso— en tiempo de build: no puedes correr código arbitrario de terceros solo para descubrir qué exporta. La única vía practicable es leerlo, y leerlo con provecho exige que su estructura sea legible sin correrlo. ESM está diseñado exactamente para que esa lectura baste.

Esa certeza es la materia prima del tree shaking. Antes de correr una sola instrucción, el bundler conoce el grafo completo: quién importa a quién, qué nombres concretos viajan por cada arista. Puede entonces partir de los puntos de entrada, marcar los bindings alcanzados y declarar muerto todo lo demás con la tranquilidad de que la ejecución no va a inventar un import nuevo a mitad de camino.

Ese es el punto que conviene grabar: el bundler puede prometer sin ejecutar. La imposibilidad de que aparezca un import sorpresa a mitad de la ejecución es lo que convierte el análisis de una conjetura razonable en una certeza demostrable. En un lenguaje donde importar pudiera ocurrir en cualquier momento y con cualquier nombre, ninguna afirmación sobre qué se usa sería definitiva hasta que el programa terminara. En ESM, esa afirmación es definitiva antes incluso de que el programa arranque.

// Legal en ESM: declaraciones estaticas, nivel superior, nombres literales
import { debounce } from './utils.js'
export const API = '/v1'

// Ilegal en ESM: no puedes condicionar ni calcular un import estatico
if (condicion) {
  import { algo } from './quiza.js'   // SyntaxError
}

Todas esas prohibiciones se condensan en una sola propiedad: la forma de un módulo ESM se conoce antes de ejecutarlo. No hace falta correr el programa para trazar su grafo de dependencias; basta con leerlo. Esa analizabilidad estática es el cimiento no solo del tree shaking, sino de casi todo lo bueno que las herramientas hacen por ti: el chequeo de tipos que cruza archivos, el ir a la definición del editor, el autocompletado que sabe qué exporta un módulo sin abrirlo, y el empaquetado en su conjunto. El tree shaking es solo el beneficiario más visible de una virtud que sostiene el ecosistema entero.

Merece la pena insistir en que estas reglas se sienten a veces como una camisa de fuerza. Uno quisiera importar condicionalmente, o calcular el nombre de un módulo, o reasignar lo que trae un import. ESM lo prohíbe, y esa prohibición es exactamente el precio de la analizabilidad: cada libertad dinámica que el lenguaje concede es una certeza que las herramientas pierden. La disciplina de lo estático no es burocracia, es el trato que hace posible que un programa razone sobre otro.

Bindings inmutables: por qué el análisis es fiable

Hay una segunda propiedad, más sutil, que blinda el análisis: los nombres que importas son bindings vivos de solo lectura, no copias. Cuando escribes import { API } from './config.js', API no es una variable local que puedas reasignar; es una vista inmutable del export original. No puedes hacer API = otra_cosa, y el módulo que la expone tampoco puede sustituir el binding por debajo sin que el sistema lo sepa.

Esa inmutabilidad es la que permite razonar composicionalmente sobre el grafo. Si resta no aparece en ningún import alcanzable, el bundler puede afirmar que nadie la usará en tiempo de ejecución, porque no existe un mecanismo legal para introducirla de tapadillo. El análisis estático es fiable precisamente porque el lenguaje prohíbe las jugadas dinámicas que lo invalidarían.

Hay aquí una diferencia con CommonJS que suele pasar inadvertida y que es decisiva. En CommonJS, const { API } = require('./config') te da una copia del valor en el momento de la llamada, una variable local que puedes reasignar y que no sabe nada del módulo de origen. En ESM, el import es un enlace vivo al binding original: si el módulo exportador actualizara su valor, tú verías el cambio, y en ningún caso puedes escribir sobre él. Esa diferencia entre copiar un valor y enlazar un binding es la que permite al bundler razonar con certeza sobre el uso, porque el conjunto de nombres que circulan por el grafo es fijo y no se puede ampliar en tiempo de ejecución.

En términos prácticos, el bundler nunca tiene que preguntarse “¿y si en tiempo de ejecución alguien introduce un uso nuevo de resta?”. En ESM esa pregunta carece de respuesta afirmativa posible: no existe sintaxis legal para hacerlo. La ausencia de esa posibilidad es, literalmente, lo que convierte “no se usa” en una conclusión demostrada y no en una apuesta prudente.

📝
import dinámico: estático en su forma, no en su carga

El import() dinámico —una función que devuelve una promesa— sí puede vivir dentro de un if y recibir un especificador variable. No contradice lo anterior: su propósito es el code splitting, no romper el análisis. Cuando el especificador es literal, el bundler crea un chunk aparte y sigue sabiendo qué exports de ese chunk se usan. Cuando es una expresión calculada, el análisis se degrada y el bundler tiende a conservarlo todo, porque ya no puede probar qué se alcanza. La estructura estática de los import de nivel superior es lo que se sacude; el import() es otra herramienta con otro fin.

Por qué CommonJS resiste la poda

CommonJS se diseñó para Node con otra mentalidad, imperativa y dinámica. Importar es llamar a una función: const x = require('./mod'). Esa llamada puede estar dentro de un if, recibir un nombre construido al vuelo (require('./plugins/' + nombre)), o ejecutarse en un bucle. Y exportar es mutar un objeto: module.exports.foo = ..., o incluso module.exports = fabrica(), con propiedades que solo existen tras ejecutar el módulo.

Nada de esto es un defecto de CommonJS; es su naturaleza, y durante años fue una virtud. Que importar sea llamar a una función y exportar sea rellenar un objeto hizo a Node flexible y fácil de razonar en tiempo de ejecución, con el módulo ya corriendo. El problema surge solo cuando pides un análisis antes de ejecutar, que es exactamente lo que el tree shaking necesita. Lo mismo que vuelve cómodo a CommonJS para el runtime lo vuelve opaco para el build: la dinámica que le da poder es la que le niega la analizabilidad.

De ahí que el bundler no pueda, en el caso general, saber qué exporta o importa un módulo CommonJS sin ejecutarlo —y ejecutar código arbitrario en tiempo de build no es una opción, ni por seguridad ni por determinismo. Ante esa indecidibilidad, la única respuesta correcta es conservadora: incluir el módulo entero. No hay forma segura de sacudir lo que no puedes analizar, así que CommonJS se lleva consigo todo su contenido, usado o no.

// CommonJS: nada de esto es decidible sin ejecutar el modulo
const nombre = calcularNombre()
const plugin = require('./plugins/' + nombre)   // especificador calculado

if (process.env.MODO === 'debug') {
  module.exports.trazar = require('./trazar')    // export condicional
}
module.exports = Object.assign({}, base, extra)  // exports rellenados en runtime

La contraposición entre ambos sistemas de módulos se ve mejor puesta en columnas, porque cada fila es una libertad de CommonJS que se paga con una certeza perdida:

Propiedad ESM CommonJS
Forma de importar declaración import estática llamada a require()
Dónde puede aparecer solo en el nivel superior en cualquier lugar
Especificador literal fijo expresión calculable
Bindings importados vistas inmutables de solo lectura valores copiados y reasignables
Exports fijos, conocidos de antemano objeto mutado en ejecución
Analizable sin ejecutar no, en el caso general

Leída de arriba abajo, la tabla cuenta una sola historia: ESM cambió expresividad por analizabilidad, y el tree shaking es el dividendo de ese cambio. Cada casilla de la columna izquierda es una puerta que el lenguaje cerró a propósito para que las herramientas pudieran entrar por ella.

🧊

Estructura estática

import y export fijos, en el nivel superior, con especificadores literales. El grafo se conoce sin ejecutar nada.

🔒

Bindings inmutables

Lo que importas es una vista de solo lectura, no una copia reasignable. Nadie puede colar un uso oculto.

🎲

require dinámico

Una llamada a función que puede ser condicional o calculada. Saber qué importa exige ejecutar el módulo.

🧱

module.exports mutable

Un objeto que se rellena en tiempo de ejecución. Sus exports no son decidibles de antemano, así que se conserva entero.

flowchart TD
subgraph ESM
  A[import y export estaticos] --> B[grafo analizable sin ejecutar]
  B --> C[se sacude lo no alcanzado]
end
subgraph CommonJS
  D[require dinamico y exports mutables] --> E[indecidible sin ejecutar]
  E --> F[se conserva el modulo entero]
end
style C fill:#a6e3a1,color:#11111b
style F fill:#f38ba8,color:#11111b

Interop, barriles y namespace imports

La estructura estática de ESM no es útil solo dentro de tu propio código; se propaga por el grafo hasta donde alcanza. Un grafo enteramente ESM es analizable de punta a punta, y el tree shaking lo recorre sin puntos ciegos. Basta con que en algún nudo aparezca un módulo dinámico —un paquete CommonJS, un especificador calculado— para que ese nudo se vuelva opaco y el análisis, prudente, dé por vivo todo lo que cuelga de él. La analizabilidad es una propiedad del grafo completo, no de cada archivo por separado.

En 2026 casi todo el ecosistema publica ESM, pero la frontera con CommonJS sigue siendo la principal fuga de tree shaking. Cuando importas una dependencia que por dentro es CJS, el bundler la envuelve para interoperar, y ese envoltorio suele ser opaco al análisis: importas un named export, pero por debajo se arrastra el objeto entero. Por eso una misma librería en su versión -es se sacude y en su versión CommonJS no.

⚠️
El barril CommonJS es la fuga clásica

El patrón que más tamaño cuela sin que nadie lo note es un barril —un index que reexporta muchos módulos— publicado en forma CommonJS. Importas un solo named export, pero como el barril es CJS su envoltorio es opaco, el bundler no puede probar que los demás reexports sobran, y los arrastra. El síntoma desconcierta: importas una función y el bundle engorda con veinte que no tocas. La cura no está en tu código sino en la dependencia —que publique ESM o declare bien su sideEffects—, pero al menos, cuando lo veas, ya sabrás exactamente dónde mirar y por qué ocurre.

Los import * as ns —namespace imports— son un caso intermedio instructivo. Si accedes a sus propiedades de forma estática (ns.debounce), un buen bundler todavía puede rastrear qué usas y podar el resto. Pero si pasas el namespace entero a una función o accedes con clave dinámica (ns[nombre]), pierdes la garantía y el análisis se rinde, conservándolo todo. La lección práctica es tratar el import * como una renuncia potencial a la poda, y preferir named imports cuando quieras que el árbol se sacuda.

El panorama de 2026 matiza pero no derriba esta jerarquía. Los bundlers modernos han aprendido heurísticas para analizar patrones comunes de CommonJS y extraer sus exports estáticos cuando el módulo se comporta de forma predecible, y herramientas como Oxc razonan sobre la interop con más finura que sus predecesoras. Pero son heurísticas, no garantías: funcionan para el caso amable y se rinden ante el caso retorcido, y siempre por el lado seguro de conservar de más. La regla estratégica no ha cambiado desde que ESM existe: publica y consume ESM siempre que puedas, y cuando dependas de un paquete CommonJS, asume que viajará más entero de lo que te gustaría.

💡
Prefiere la versión ESM de una dependencia cuando exista

Muchas librerías publican dos formas del mismo código: una CommonJS heredada y una ESM moderna, a veces con el sufijo -es en el nombre o seleccionada por el campo exports del package.json. Si te importa el tamaño, comprueba cuál estás consumiendo de verdad, porque la diferencia en poda puede ser abismal: la misma función importada desde la variante ESM arrastra su cierre transitivo y poco más, mientras que desde la variante CommonJS puede traerse la librería entera. En un ecosistema que en 2026 ya es mayoritariamente ESM, quedarse atado a la forma CommonJS de una dependencia es, muchas veces, pagar tamaño sin darse cuenta.

La analizabilidad es una decisión de diseño, y el tree shaking es su recompensa

Conviene entender que ESM no habilita el tree shaking por casualidad, sino por una elección deliberada que costó discusiones enconadas en su día. Cuando se estandarizaron los módulos de JavaScript, había una tensión real entre dos filosofías: la dinámica y flexible de CommonJS, donde importar es ejecutar y exportar es mutar, y la estática y restrictiva de ESM, donde importar y exportar son declaraciones fijas que el sistema puede leer de antemano. La segunda ganó, y lo hizo citando explícitamente la eliminación de código muerto entre sus motivaciones. Las restricciones que a veces maldices —no poder poner un import dentro de un if, no poder reasignar lo que importas, tener que usar especificadores literales— no son burocracia del lenguaje: son exactamente el precio que se paga para que una herramienta pueda razonar sobre tu código sin ejecutarlo. Aquí hay un principio que trasciende a los módulos y reaparece en todo el diseño de sistemas: la analizabilidad es un recurso, y se compra restringiendo la expresividad. Un lenguaje que te deja hacer cualquier cosa en cualquier sitio es cómodo de escribir y hostil de analizar; un lenguaje que te ata las manos en los sitios correctos regala a las herramientas la capacidad de optimizar, verificar y transformar con garantías. CommonJS eligió la libertad y pagó con la opacidad; ESM eligió la disciplina y cobró en poda, en tipos, en análisis. Cuando ves que una dependencia no se sacude, casi siempre el diagnóstico de fondo es el mismo: en algún punto el grafo cruzó de lo estático a lo dinámico —un envoltorio CJS, un namespace pasado entero, un especificador calculado— y en ese punto la herramienta perdió la certeza y tuvo que ser conservadora. Optimizar el tamaño de un bundle es, en el fondo, defender la analizabilidad del grafo de las incursiones de lo dinámico. Quien entiende que el tree shaking vive y muere con la estructura estática deja de tratarlo como una casilla que se activa y empieza a tratarlo como una propiedad que se cultiva: importa estático, exporta nombres fijos, aísla lo dinámico, y el árbol se sacudirá solo.

⚔️ Prueba los límites de lo estático
  1. Intenta escribir un import estático dentro de un if y observa el SyntaxError; explica qué garantía protege esa prohibición.
  2. Compara el bundle de una librería en su forma ESM (lodash-es) frente a su forma CommonJS (lodash) importando la misma función.
  3. Convierte un import * as ns con acceso estático en uno que pase el namespace entero a una función y comprueba cómo cambia lo que sobrevive.
  4. Localiza en un build tuyo una dependencia CommonJS y razona por qué el bundler la conserva completa aunque uses una parte.
  5. Escribe en una frase por qué los bindings inmutables de ESM son condición necesaria para que el análisis de alcance sea fiable.