Tree Shaking und Bundle-Analyse in der Praxis
60fps
ms
Web Performance · Tree Shaking · Bundling · JavaScript
Tree Shaking und Bundle-Analyse
in der Praxis

Ungenutzter Code landet erstaunlich häufig trotzdem im Produktions-Bundle, weil CommonJS-Imports, Barrel-Files oder eine falsch gesetzte sideEffects-Flag den Bundler daran hindern, ihn zu entfernen. Dieser Artikel zeigt mechanisch, wie Tree Shaking funktioniert, welche Muster es blockieren, und wie Bundle-Analyzer-Tools die Ursachen mit konkreten Kilobyte-Zahlen sichtbar machen.

17 Min. Lesezeit Tree Shaking · sideEffects · Bundle-Analyzer Webpack · Rollup · Vite

1. Wie Tree Shaking mechanisch funktioniert

Tree Shaking ist keine Magie, sondern statische Analyse eines Import/Export-Graphen. Der Bundler baut beim Start einen vollständigen Abhängigkeitsbaum aus allen Modulen: Jede import-Anweisung wird als Kante zwischen zwei Knoten interpretiert, jedes export als potenzieller Einstiegspunkt. Weil ES-Module-Imports und -Exports statisch sind, also zur Compile-Zeit feststehen und nicht zur Laufzeit berechnet werden, kann der Bundler exakt bestimmen, welche Exports irgendwo tatsächlich referenziert werden und welche nicht. Nicht referenzierte Exports werden als "dead" markiert.

Das eigentliche Entfernen des toten Codes übernimmt in der Regel nicht der Bundler selbst, sondern der nachgeschaltete Minifier, meist Terser oder esbuild. Der Bundler markiert ungenutzten Code mit Kommentaren wie /*#__PURE__*/ oder entfernt die entsprechenden Export-Bindings aus dem Modul-Graph, und der Minifier führt dann die eigentliche Dead-Code-Elimination beim Komprimieren durch. Das erklärt, warum ein unminifiziertes Development-Bundle oft noch den vollständigen, ungeshakten Code enthält, obwohl Tree Shaking technisch bereits aktiv war.

Der entscheidende Grund, warum das nur mit ESM funktioniert und nicht mit CommonJS: Bei import { debounce } from 'lodash-es' weiß der Bundler bereits beim Parsen, welche Bindung importiert wird. Bei require('lodash') ist der komplette Ausdruck erst zur Laufzeit auswertbar, weil require() eine normale Funktion ist, die theoretisch mit jedem beliebigen String aufgerufen werden könnte. Diese fundamentale Eigenschaft des Modulsystems ist die Wurzel fast aller Tree-Shaking-Probleme in der Praxis.

2. Die sideEffects-Flag in package.json

Selbst mit reinem ESM kann ein Bundler nicht automatisch wissen, ob das Ausführen eines Moduls unabhängig von seinen Exports einen relevanten Effekt hat, etwa das Registrieren eines Custom Elements, das Patchen eines globalen Prototyps oder das Einbinden von CSS. Deshalb gibt es die sideEffects-Flag in package.json. Sie ist eine explizite Zusicherung des Paket-Autors an den Bundler: "Diese Module haben, außer ihren Exports, keine beobachtbaren Effekte, du darfst ungenutzte Imports komplett entfernen."

"sideEffects": false ist die aggressivste Einstellung und erlaubt dem Bundler, jedes Modul zu entfernen, dessen Exports nirgends verwendet werden, selbst wenn das Modul beim Import theoretisch Code ausführt. Das ist nur sicher, wenn wirklich kein Modul im Paket globale Effekte hat. Realistischer ist meist ein Array mit expliziten Ausnahmen, etwa ["*.css", "./src/polyfills.js"], für Dateien mit echten Seiteneffekten, die niemals eliminiert werden dürfen, unabhängig davon, ob ihre Exports genutzt werden.

Der Risikofaktor bei falscher Konfiguration ist real: Setzt man sideEffects: false für ein Paket, das tatsächlich globale Effekte hat, zum Beispiel einen Polyfill, der Array.prototype erweitert, entfernt der Bundler das Modul, weil kein Export direkt referenziert wird, und der Polyfill fehlt zur Laufzeit. Solche Bugs treten oft erst in Produktion auf, weil Development-Builds häufig ohne aggressive Optimierung laufen und den Fehler maskieren.


{
  "name": "@mironsoft/ui-kit",
  "version": "2.4.0",
  "type": "module",
  "sideEffects": [
    "*.css",
    "./src/polyfills/intl-polyfill.js",
    "./src/global-styles.js"
  ]
}

3. CommonJS als Tree-Shaking-Blocker

require() ist eine ganz normale JavaScript-Funktion, kein Sprachkonstrukt. Sie kann mit einer berechneten Variablen, in einer Bedingung oder in einer Schleife aufgerufen werden. Ein Bundler kann diesen Aufruf deshalb nur statisch analysieren, wenn das Argument ein Literal-String ist, und selbst dann bleibt unklar, welche Eigenschaften des zurückgegebenen module.exports-Objekts tatsächlich verwendet werden, weil Objektzugriffe in JavaScript beliebig dynamisch sein können (obj[key]). Importiert man aus einem reinen CommonJS-Paket, zieht man deshalb in der Regel das gesamte module.exports-Objekt ins Bundle, selbst wenn nur eine einzige Funktion genutzt wird.

Moderne Bundler wie Webpack und Rollup versuchen das teilweise zu kompensieren, indem sie CommonJS-Module über ein Interop-Plugin in eine synthetische ESM-Hülle wrappen und mit statischer Analyse versuchen, ungenutzte Property-Zugriffe zu erkennen. Das funktioniert bei einfachen, flachen Exports, scheitert aber häufig bei Re-Exports, dynamischen Objektkonstruktionen oder wenn das Paket Object.defineProperty zur Laufzeit nutzt, um Exports zu definieren.

Um das Modulformat einer Dependency zu prüfen, lohnt sich ein Blick in ihre package.json: Ein "type": "module" oder ein separates "module"-Feld neben "main" zeigt an, dass ein ESM-Build existiert. Fehlt beides und liefert das Paket nur eine main-Datei mit module.exports, ist es reines CommonJS, und ein Import zieht garantiert das komplette Paket mit sich.


// CommonJS: require() is a plain function call, not statically analyzable.
// The bundler cannot know which exports are actually used at build time.
const { debounce } = require('lodash');
// Result: the entire lodash package (~70 KB minified) is included,
// even though only one function is referenced.

// ESM: static import/export bindings, resolvable at parse time.
import { debounce } from 'lodash-es';
// Result: only the debounce module and its direct dependencies
// are pulled into the bundle (a few KB).

4. Barrel-Files und ihre versteckten Kosten

Ein Barrel-File ist eine index.js, die alle Module eines Ordners zentral re-exportiert, üblicherweise mit export * from './button'; export * from './modal'; export * from './tooltip';. Das Muster ist bequem für Konsumenten, weil ein einziger Import-Pfad reicht, ist aber ein bekannter Tree-Shaking-Risikofaktor. Ob der Bundler die ungenutzten Re-Exports trotzdem entfernen kann, hängt stark von der konkreten Bundler-Version, der Modulauflösung und der sideEffects-Konfiguration des Pakets ab.

Das praktische Problem verschärft sich, wenn eines der re-exportierten Module selbst einen Seiteneffekt hat, etwa das automatische Registrieren einer globalen Instanz. Ist die Barrel-Datei nicht explizit als sideEffects: false markiert, muss der Bundler im Zweifel konservativ bleiben und das gesamte Modul inklusive aller Geschwister-Exports behalten, weil er nicht ausschließen kann, dass ein Import irgendeinen notwendigen Effekt auslöst. Bei großen Komponentenbibliotheken mit hunderten Exports in einer Barrel-Datei kann das den Unterschied zwischen einem 15-KB- und einem 400-KB-Bundle ausmachen.

Die zuverlässigste Gegenmaßnahme ist der direkte Import aus der konkreten Datei statt aus dem Barrel: import { Button } from '@ui/components/button' statt import { Button } from '@ui/components'. Das umgeht die Unsicherheit komplett, weil der Bundler nur noch genau die referenzierten Module auflösen muss. Tools wie babel-plugin-transform-imports oder die eingebaute Optimierung mancher Frameworks (etwa Next.js' optimizePackageImports) automatisieren diese Umschreibung, ohne dass Entwickler ihre Imports manuell anpassen müssen.


// index.js: barrel file that re-exports the entire component folder
export * from './button';
export * from './modal';
export * from './tooltip';
export * from './data-table'; // pulls in a heavy charting dependency

// Consumer code: only Button is needed, but the bundler may still
// have to keep the whole barrel graph if sideEffects isn't declared.
import { Button } from '@ui/components';

// Fix: import directly from the specific file, bypassing the barrel.
import { Button } from '@ui/components/button';

5. Seiteneffektbehaftete Imports erkennen

Nicht jeder scheinbar ungenutzte Import darf entfernt werden. Klassische Beispiele sind CSS-Imports wie import './styles.css', die keinen JavaScript-Export haben, aber beim Build in die Stylesheet-Ausgabe eingebunden werden müssen. Ebenso Polyfills wie import 'core-js/stable', die global Prototypen erweitern, oder Module, die beim Laden etwas in einer Registry anmelden, etwa import './icons/register-all', das Icon-Komponenten in einem globalen Store registriert, ohne selbst etwas zu exportieren, das direkt referenziert wird.

Diese Imports müssen explizit als Seiteneffekt markiert werden, entweder über das sideEffects-Array in package.json oder, projektintern, über die entsprechende Konfiguration im Bundler. Fehlt diese Markierung und ist sideEffects: false global gesetzt, eliminiert der Bundler den Import beim nächsten Build, weil er keinen referenzierten Export findet, und die Anwendung bricht zur Laufzeit auf eine Weise, die im Code-Review kaum auffällt, weil der Import-Statement selbst unverändert im Quellcode steht.

Ein zuverlässiger Test in der Praxis: Nach jeder Änderung an der sideEffects-Konfiguration einen vollständigen Production-Build inklusive Minifizierung ausführen und die Anwendung end-to-end testen, nicht nur im Development-Modus. Development-Builds deaktivieren aggressives Tree Shaking meist komplett, sodass Fehler durch falsch markierte Seiteneffekte erst im Produktions-Build oder, schlimmer, erst live sichtbar werden.

6. Bundle-Analyzer im Einsatz: webpack-bundle-analyzer

webpack-bundle-analyzer visualisiert den Inhalt eines Webpack-Bundles als interaktive Treemap: Jedes Rechteck ist ein Modul, die Fläche entspricht der Größe im Bundle. Module, die in mehreren Chunks vorkommen, werden farblich hervorgehoben, was doppelt gebündelten Code sofort sichtbar macht, ein häufiges Problem bei falsch konfiguriertem Code-Splitting. Die Treemap unterscheidet zwischen "stat size" (Größe vor Minifizierung), "parsed size" (nach Minifizierung) und "gzip size", was hilft einzuschätzen, wie stark ein Modul tatsächlich zur ausgelieferten Netzwerklast beiträgt.

Beim Lesen der Treemap lohnt sich der Blick auf ungewöhnlich große Rechtecke an unerwarteten Stellen: Ein moment/locale-Ordner, der plötzlich 300 KB einnimmt, weil versehentlich alle Sprachpakete statt nur der benötigten importiert wurden, ist ein klassischer Fund. Ebenso auffällig sind mehrfach vorkommende Versionen derselben Bibliothek, etwa lodash@3 und lodash@4 gleichzeitig im Baum, weil verschiedene Dependencies unterschiedliche Versionsanforderungen haben und der Package-Manager sie nicht deduplizieren konnte.

Der Einstieg erfordert kaum Konfiguration: Das Plugin lässt sich temporär per CLI ausführen, ohne die Webpack-Konfiguration dauerhaft zu ändern, was es ideal für schnelle Stichproben macht, etwa direkt nach einem Dependency-Update, um zu prüfen, ob sich die Bundle-Größe unerwartet verändert hat.


# Quick one-off analysis without permanently changing webpack.config.js
npx webpack --profile --json > stats.json
npx webpack-bundle-analyzer stats.json dist/ --port 8888

# Or wired into the config for repeated use during development
# webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',
      openAnalyzer: false,
      reportFilename: 'bundle-report.html',
    }),
  ],
};

7. Alternativen: source-map-explorer und rollup-plugin-visualizer

source-map-explorer nutzt vorhandene Source Maps, um die Bundle-Zusammensetzung zurück auf den ursprünglichen Quellcode abzubilden, unabhängig davon, mit welchem Bundler das Bundle gebaut wurde. Das macht es besonders nützlich für Setups, die nicht auf Webpack basieren, oder für Legacy-Build-Pipelines mit mehreren verketteten Tools, bei denen ein webpack-spezifisches Analyzer-Plugin nicht direkt einsetzbar ist. Voraussetzung ist ein Production-Build mit aktivierten Source Maps (devtool: 'source-map' in Webpack oder das entsprechende Rollup-Äquivalent), was in Produktionsumgebungen aus Sicherheitsgründen oft standardmäßig deaktiviert ist und temporär reaktiviert werden muss.

rollup-plugin-visualizer ist das Pendant für Rollup- und Vite-basierte Projekte und erzeugt ebenfalls eine Treemap oder wahlweise ein Sunburst- oder Netzwerkdiagramm direkt aus dem Rollup-Build-Prozess. Da Vite intern Rollup für Production-Builds nutzt, funktioniert das Plugin nahtlos in Vite-Projekten und zeigt zusätzlich an, welcher Chunk durch dynamisches import() entstanden ist, was bei der Bewertung von Route-basiertem Code-Splitting hilft.

Für CI-Integration und Budget-Enforcement eignet sich keines der visuellen Tools direkt, da sie primär für die manuelle Inspektion gedacht sind. Stattdessen kombiniert man sie mit bundlesize oder der eingebauten performance.maxAssetSize-Option in Webpack, die den Build fehlschlagen lässt, sobald ein definiertes KB-Budget überschritten wird. Die Analyzer-Reports werden dann als Artefakt in der Pipeline gespeichert und bei Budget-Überschreitung zur manuellen Ursachenanalyse herangezogen, statt bei jedem Build automatisch geöffnet zu werden.


# source-map-explorer: works with any bundler that emits source maps
npx source-map-explorer dist/main.js dist/main.js.map --html report.html

# rollup-plugin-visualizer: for Rollup and Vite projects
# vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';

export default {
  build: { sourcemap: true },
  plugins: [
    visualizer({ filename: 'bundle-report.html', gzipSize: true, brotliSize: true }),
  ],
};

8. Praktischer Workflow: totem Code finden und entfernen

Ein systematischer Workflow beginnt nicht beim Zeilen-für-Zeilen-Lesen des Codes, sondern beim Bundle-Analyzer-Report: Die größten Chunks zuerst untersuchen, weil dort das größte Einsparpotenzial liegt. Ein 2-KB-Modul lohnt selten die Untersuchung, ein 150-KB-Chunk mit unklarer Herkunft dagegen immer. Aus dem Treemap-Report lassen sich die größten Pakete identifizieren, danach prüft man pro Paket, ob es tatsächlich mit voller Funktionalität benötigt wird oder ob eine leichtere Alternative existiert.

Parallel zur Bundle-Analyse lohnt sich der Einsatz von depcheck oder dem moderneren knip, die den Quellcode statisch nach Imports durchsuchen und mit den in package.json deklarierten Dependencies abgleichen. Beide Tools finden zwei unterschiedliche Problemklassen: ungenutzte Dependencies, die komplett aus package.json entfernt werden können, und fehlende Dependencies, die implizit über eine andere Dependency mitgeliefert werden und bei einem Versions-Update plötzlich fehlen könnten. knip geht dabei weiter als depcheck und findet zusätzlich unbenutzte Exports innerhalb des eigenen Codes, nicht nur unbenutzte externe Pakete.

Der letzte Schritt jedes Zyklus ist die Verifikation: Nach jeder Entfernung einen vollständigen Production-Build laufen lassen, die Bundle-Größe erneut messen und die betroffenen Funktionsbereiche der Anwendung manuell oder per E2E-Test durchklicken. Toter Code ist selten wirklich zu 100 % tot, gelegentlich wird ein vermeintlich ungenutzter Export doch noch über einen dynamischen Import oder eine Reflection-artige Konstruktion referenziert, die statische Analyse-Tools nicht erfassen.

9. Vorher/Nachher-Beispiel mit echten Zahlen

Ein realistisches Beispiel aus einem mittelgroßen Frontend-Projekt: Der ursprüngliche Vendor-Chunk lag bei 412 KB minifiziert, 128 KB gzip. Die Bundle-Analyzer-Treemap zeigte drei auffällige Blöcke: lodash komplett importiert über const _ = require('lodash') mit rund 71 KB minifiziert, obwohl im Code nur vier Funktionen (debounce, cloneDeep, groupBy, isEqual) genutzt wurden; moment.js mit allen 200+ Locale-Dateien bei 289 KB minifiziert, obwohl nur die deutsche und englische Locale gebraucht wurden; und ein Barrel-Import aus einer internen UI-Bibliothek, der versehentlich eine 40-KB-Chart-Komponente mitzog, die auf der betroffenen Seite gar nicht gerendert wurde.

Nach der Umstellung auf gezielte Named Imports aus lodash-es, dem Ersatz von moment.js durch die native Intl.DateTimeFormat-API für die zwei tatsächlich benötigten Locales, und dem direkten Import der Button-Komponente statt des Barrel-Files sank der Vendor-Chunk auf 94 KB minifiziert, 31 KB gzip. Das entspricht einer Reduktion von rund 77 % minifiziert und 76 % gzip, bei identischer Funktionalität und ohne sichtbare Verhaltensänderung für den Endnutzer. Die gemessene Verbesserung der Time to Interactive auf einem Mid-Tier-Mobilgerät lag bei knapp 380 Millisekunden, gemessen über drei Lighthouse-Läufe im Median.


// BEFORE: full CommonJS import pulls in the entire lodash package (~71 KB min)
const _ = require('lodash');
const result = _.debounce(fn, 300);

// BEFORE: moment.js with all locale files bundled (~289 KB min)
import moment from 'moment';
import 'moment/locale/de';
const formatted = moment(date).format('DD.MM.YYYY');

// AFTER: named ESM import, only the used function is bundled (~2 KB min)
import { debounce } from 'lodash-es';
const result = debounce(fn, 300);

// AFTER: native Intl API, zero extra dependency weight
const formatted = new Intl.DateTimeFormat('de-DE').format(date);
Muster Bundle-Impact Empfehlung
import _ from 'lodash' ~71 KB min, kein Shaking import { debounce } from 'lodash-es'
require() für Named Exports komplettes Modul geladen ESM-Import statt CommonJS
moment.js mit allen Locales ~289 KB min native Intl.DateTimeFormat
Import aus Barrel-index.js zieht Geschwister-Module mit Direkt-Import aus der Datei
Keine sideEffects-Flag Bundler bleibt konservativ sideEffects korrekt deklarieren

Mironsoft

Bundle-Optimierung, Frontend-Performance und Hyvä-Build-Pipelines

Zu große JavaScript-Bundles im Shop?

Wir analysieren eure Bundle-Zusammensetzung mit den passenden Tools, finden CommonJS-Blocker, Barrel-File-Fallen und falsch konfigurierte sideEffects-Flags und setzen die Optimierungen direkt in eurer Build-Pipeline um.

Bundle-Audit

Treemap-Analyse und Priorisierung nach Kilobyte-Einsparpotenzial

Dependency-Cleanup

Ungenutzte Pakete und CommonJS-Blocker gezielt ersetzen

CI-Budget-Gates

Bundle-Größenbudgets in der Pipeline gegen Regressionen absichern

10. Zusammenfassung

Tree Shaking und Bundle-Analyse lösen dasselbe Grundproblem aus zwei Richtungen: Tree Shaking verhindert präventiv, dass ungenutzter Code überhaupt ins Bundle gelangt, Bundle-Analyzer machen sichtbar, wo das trotzdem passiert ist. Voraussetzung für funktionierendes Shaking ist reines ESM statt CommonJS, eine korrekt gesetzte sideEffects-Flag und der bewusste Verzicht auf Barrel-Imports an Stellen, wo nur ein einzelnes Modul benötigt wird. Fehlt eine dieser drei Voraussetzungen, bleibt der Bundler konservativ und behält Code, der eigentlich entfernt werden könnte.

Werkzeuge wie webpack-bundle-analyzer, source-map-explorer und rollup-plugin-visualizer ersetzen das Raten durch Messen: Statt zu vermuten, welches Paket das Bundle aufbläht, zeigt die Treemap es direkt an. Kombiniert mit depcheck oder knip für ungenutzte Dependencies und einem CI-Budget-Gate gegen Regressionen entsteht ein wiederholbarer Workflow, der Bundle-Größe dauerhaft unter Kontrolle hält, statt sie nur einmalig zu optimieren.

Tree Shaking und Bundle-Analyse - Das Wichtigste auf einen Blick

Nur ESM ist shakebar

Statische Import/Export-Analyse funktioniert nur mit ES Modules. require() ist dynamisch und blockiert das Shaking.

sideEffects korrekt setzen

sideEffects: false oder ein präzises Array mit echten Ausnahmen wie CSS-Dateien und Polyfills.

Barrel-Files vermeiden

Direkter Import aus der konkreten Datei statt aus der zentralen index.js, wenn nur ein Modul benötigt wird.

Messen statt raten

webpack-bundle-analyzer, source-map-explorer oder rollup-plugin-visualizer plus CI-Budget-Gates.

11. FAQ: Tree Shaking und Bundle-Analyse

1Was ist Tree Shaking genau?
Statische Analyse des Import/Export-Graphen, um ungenutzte Exports zu identifizieren. Der Bundler markiert sie als tot, der Minifier entfernt sie beim Komprimieren.
2Warum funktioniert Tree Shaking nicht mit CommonJS?
require() ist eine dynamische Funktion, kein statisches Konstrukt. Der Bundler kann nicht sicher bestimmen, welche Teile gebraucht werden, und behält im Zweifel das gesamte Modul.
3Was macht die sideEffects-Flag?
Teilt dem Bundler mit, ob Module beobachtbare Effekte haben. false erlaubt aggressives Entfernen, ein Array listet Ausnahmen wie CSS-Imports und Polyfills.
4Was ist ein Barrel-File und warum ist es riskant?
Eine index.js, die alle Module eines Ordners re-exportiert. Ohne korrekte sideEffects-Konfiguration muss der Bundler oft den gesamten Graphen behalten.
5Wie erkenne ich CommonJS vs. ESM bei Dependencies?
"type": "module" oder ein "module"-Feld neben "main" in der package.json deutet auf ESM hin. Nur main mit module.exports bedeutet reines CommonJS.
6Was zeigt webpack-bundle-analyzer an?
Eine interaktive Treemap mit stat-, parsed- und gzip-Größe pro Modul, inklusive Markierung mehrfach gebündelter Module.
7Wann source-map-explorer statt webpack-bundle-analyzer?
Bei Nicht-Webpack-Setups oder mehreren verketteten Build-Tools. Es arbeitet bundler-unabhängig direkt mit Source Maps.
8Wie finde ich ungenutzte Dependencies?
depcheck oder knip durchsuchen den Code statisch nach Imports und gleichen sie mit package.json ab. knip findet zusätzlich ungenutzte eigene Exports.
9Wie verhindere ich Bundle-Regressionen in der CI?
Mit einem Budget-Gate wie bundlesize oder performance.maxAssetSize, das den Build bei Überschreitung fehlschlagen lässt, plus gespeichertem Analyzer-Report als Artefakt.
10Warum bricht meine App nach sideEffects: false?
Vermutlich enthält ein Paket ein Modul mit echtem globalem Effekt, das ohne referenzierten Export entfernt wird. Diese Dateien müssen explizit ausgenommen werden.