Code-Splitting und Lazy Loading fuer kleinere Bundles
60fps
ms
Performance · Code Splitting · Bundling · JavaScript
Code-Splitting und Lazy Loading fuer kleinere Bundles
Weniger JavaScript beim ersten Laden ausliefern

Ein grosses JavaScript-Bundle kostet nicht nur Download-Zeit, sondern blockiert den Main Thread beim Parsen und Kompilieren. Mit dynamic import(), Vendor-Chunk-Splitting und gezieltem Nachladen schwerer Widgets unterhalb des Folds laesst sich die initiale Bundle-Groesse spuerbar senken, ohne Funktionalitaet zu verlieren, auch in server-gerenderten Hyva-Shops mit Alpine.js.

14 Min. Lesezeit dynamic import() · Vendor Chunks · IntersectionObserver Hyva Theme · Alpine.js · Vite/Webpack

1. Warum Bundle-Groesse ueberhaupt ein Problem ist

Ein grosses JavaScript-Bundle ist nicht nur ein Download-Problem. Sobald die Datei im Browser ankommt, muss sie geparst, kompiliert und ausgefuehrt werden, und all das passiert grossteils auf dem Main Thread, demselben Thread, der auch fuer Rendering und Nutzerinteraktion zustaendig ist. Auf einem schnellen Desktop-Rechner mit moderner CPU faellt dieser Parse- und Compile-Aufwand kaum auf. Auf einem Mittelklasse-Smartphone mit deutlich schwaecherer CPU kann derselbe Code mehrere hundert Millisekunden zusaetzliche Blockierzeit erzeugen, selbst wenn die Datei ueber eine schnelle Verbindung in Millisekunden geladen wurde.

Diese Diskrepanz zwischen Netzwerkzeit und CPU-Zeit ist der eigentliche Grund, warum Bundle-Groesse als eigene Metrik behandelt wird. Total Blocking Time (TBT) und Interaction to Next Paint (INP) reagieren direkt auf ungenutzten, aber trotzdem ausgefuehrten Code: Jede Zeile, die beim ersten Render nicht gebraucht wird, kostet CPU-Zyklen, die an anderer Stelle fuer Reaktionsfaehigkeit fehlen. Code-Splitting greift genau hier an, indem es nicht sofort benoetigten Code in separate Dateien auslagert, die erst dann geladen und ausgefuehrt werden, wenn sie tatsaechlich gebraucht werden. Das Ziel ist nicht weniger Code insgesamt, sondern weniger Code im kritischen Pfad des ersten Renders.

2. Dynamic import() Syntax im Detail

Die dynamische import()-Funktion unterscheidet sich fundamental von der statischen import-Anweisung am Dateianfang. Statische Imports werden zur Build-Zeit aufgeloest und landen typischerweise im selben Bundle wie der aufrufende Code. Ein dynamischer import('./modul.js')-Aufruf hingegen gibt ein Promise zurueck, das erst zur Laufzeit aufgeloest wird, und genau dieses Verhalten erkennen Bundler wie Webpack, Rollup und Vite automatisch: Sie analysieren den Code statisch, finden jeden import()-Aufruf und erzeugen dafuer eine eigene Chunk-Datei, die per Script-Tag oder Fetch nachgeladen wird, sobald der Aufruf im Code tatsaechlich erreicht wird.

Weil import() ein normales Promise liefert, laesst es sich mit await, .then() und auch bedingt innerhalb von if-Bloecken verwenden, was mit statischen Imports nicht moeglich ist. Das macht dynamische Imports zum technischen Grundbaustein fuer jede Form von Lazy Loading, egal ob route-basiert oder komponentenbasiert. Wichtig fuer die Praxis: Der Import-Pfad muss fuer den Bundler statisch analysierbar sein, ein vollstaendig dynamisch zusammengesetzter String als Pfad verhindert in vielen Faellen die korrekte Chunk-Erzeugung.


// Static import: always part of the initial bundle graph
// import { ReviewsWidget } from './widgets/reviews.js';

// Dynamic import: returns a Promise, bundler creates a separate chunk
async function loadReviewsWidget() {
  // Webpack/Rollup/Vite detect this call at build time
  // and emit a standalone chunk file for it
  const module = await import('./widgets/reviews.js');
  return module.ReviewsWidget;
}

// Usage: only fetched and executed when actually called
document.getElementById('load-reviews-btn').addEventListener('click', async () => {
  const ReviewsWidget = await loadReviewsWidget();
  ReviewsWidget.mount(document.getElementById('reviews-container'));
});

3. Route-basiertes vs komponentenbasiertes Splitting

In klassischen Single-Page-Applications mit Client-seitigem Router, etwa React Router oder Vue Router, ist Route-basiertes Splitting die naheliegende Strategie: Jede Route bekommt ihren eigenen Chunk, der erst beim Navigieren zu dieser Route geladen wird. Der Nutzer laedt beim ersten Seitenaufruf nur den Code fuer die Startseite, nicht den Code fuer Checkout, Konto oder Suche. Diese Strategie funktioniert deshalb so gut, weil der Router als natuerliche Grenze fuer Chunk-Aufteilung dient, jede Route ist ein klar abgegrenztes Stueck Anwendungslogik.

Komponentenbasiertes Splitting verfolgt einen anderen Ansatz: Nicht die Route, sondern eine einzelne schwere Komponente wird als Chunk-Grenze definiert, unabhaengig davon, auf welcher Seite sie erscheint. Ein Bildergalerie-Widget, ein Chart, ein Rich-Text-Editor oder ein Video-Player werden als eigener Chunk ausgelagert, weil sie selten sofort gebraucht werden und oft erhebliches Gewicht mitbringen. Fuer serverseitig gerenderte Systeme ohne Client-Routing, wie es bei Magento und Hyva der Fall ist, entfaellt die Route als Splitting-Grenze vollstaendig. Dort ist komponentenbasiertes Splitting die einzig sinnvolle Strategie, angewendet auf einzelne interaktive Widgets statt auf ganze Seiten.

4. Vendor-Chunk-Splitting

Neben eigenem Anwendungscode enthaelt fast jedes Bundle auch Drittanbieter-Bibliotheken aus node_modules. Diese Abhaengigkeiten aendern sich deutlich seltener als der eigene Code, meist nur bei einem gezielten Dependency-Update. Vendor-Chunk-Splitting trennt genau diesen Code in eine eigene Datei, die unabhaengig vom Anwendungscode mit langen Cache-Headern ausgeliefert werden kann. Aendert sich nur die eigene Anwendungslogik, laedt der Browser weiterhin den bereits gecachten Vendor-Chunk aus dem Cache und muss nur den kleineren App-Chunk neu herunterladen.

Technisch wird das ueber die Bundler-Konfiguration gesteuert, bei Webpack ueber splitChunks.cacheGroups, bei Vite ueber rollupOptions.output.manualChunks. Beide erlauben es, Pfadmuster wie node_modules gezielt einer eigenen Chunk-Gruppe zuzuweisen, teilweise sogar granular pro Bibliothek, etwa um Alpine.js separat von kleineren Hilfsbibliotheken zu buendeln. Der Effekt ist besonders bei haeufigen Deployments spuerbar: Ohne Vendor-Splitting laedt jeder Nutzer nach jedem Release das komplette Bundle neu, mit Vendor-Splitting bleibt der grosse, stabile Anteil im Browser-Cache erhalten.


// vite.config.js - separate stable vendor code from frequently changing app code
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // Alpine.js changes rarely, cache it separately with a long max-age
          'vendor-alpine': ['alpinejs'],
          // Group other third-party dependencies together
          'vendor-libs': ['swiper', 'photoswipe'],
        },
      },
    },
  },
};

// webpack.config.js equivalent
module.exports = {
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendorAlpine: {
          test: /[\\/]node_modules[\\/]alpinejs[\\/]/,
          name: 'vendor-alpine',
          chunks: 'all',
        },
      },
    },
  },
};

5. Below-the-fold JavaScript lazy laden am Beispiel Reviews-Widget

Ein Bewertungs-Widget am Ende einer Produktseite ist ein typisches Beispiel fuer Code, der beim ersten Render fast nie sichtbar ist. Trotzdem wird sein JavaScript in vielen Shops im initialen Bundle mitgeliefert, inklusive Sortierlogik, Pagination und eventuell einem Sterne-Rendering mit eigener Chart-Bibliothek. Mit dem IntersectionObserver-Interface laesst sich das gezielt vermeiden: Der Observer beobachtet den Container des Widgets und loest erst dann einen Callback aus, wenn das Element tatsaechlich in den Viewport eintritt oder sich ihm naehert.

Die rootMargin-Option ist hier entscheidend fuer ein sauberes Nutzererlebnis: Ein Wert wie "200px" startet den Ladevorgang bereits, bevor der Nutzer das Element tatsaechlich sieht, sodass der Chunk im Idealfall schon vollstaendig geladen ist, wenn das Widget in den sichtbaren Bereich scrollt. Ohne diesen Vorlauf wuerde der Nutzer kurz einen leeren Platzhalter oder einen Lade-Spinner sehen, was die wahrgenommene Performance trotz kleinerem initialem Bundle verschlechtern kann.


// Lazy-load the reviews widget only when it approaches the viewport
const reviewsContainer = document.getElementById('reviews-container');

const observer = new IntersectionObserver(
  async (entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) {
        // Stop observing immediately to avoid duplicate imports
        observer.unobserve(entry.target);

        const { ReviewsWidget } = await import('./widgets/reviews.js');
        ReviewsWidget.mount(entry.target, {
          productId: entry.target.dataset.productId,
        });
      }
    }
  },
  {
    // Start loading 200px before the widget enters the viewport
    rootMargin: '200px 0px',
    threshold: 0,
  }
);

observer.observe(reviewsContainer);

6. Tradeoffs: mehr Requests vs kleineres initiales Bundle

Code-Splitting ist kein Optimierungsziel ohne Kosten. Jeder ausgelagerte Chunk bedeutet einen zusaetzlichen Netzwerk-Request, und dieser Request folgt in vielen Faellen einer Waterfall-Logik: Erst muss der Trigger eintreten, etwa ein Scroll-Ereignis oder ein Klick, dann startet der Request, dann muss die Antwort geparst und ausgefuehrt werden, bevor das Widget tatsaechlich nutzbar ist. Bei HTTP/2 und HTTP/3 ist das Grundproblem vieler kleiner Requests, naemlich die begrenzte Anzahl paralleler Verbindungen aus HTTP/1.1-Zeiten, weitgehend geloest, weil Multiplexing viele Anfragen ueber eine einzige Verbindung gleichzeitig abwickelt.

Trotzdem bleibt ein Punkt bestehen, an dem zu granulares Splitting schadet: Jede zusaetzliche Datei bringt eigenen HTTP-Overhead durch Header, eigene Kompressions-Dictionaries bei Gzip oder Brotli, die bei vielen kleinen Dateien schlechter greifen als bei einer einzelnen groesseren Datei, und im Fall von verketteten Abhaengigkeiten mehrere Roundtrips hintereinander statt parallel. Die praktische Regel: Logisch zusammengehoerender Code gehoert in denselben Chunk, nicht jede einzelne Funktion verdient eine eigene Datei. Ein Chunk pro eigenstaendigem Feature oder Widget ist meist die richtige Granularitaet, nicht ein Chunk pro Modul.

7. Code-Splitting im Alpine.js/Hyva-Kontext

Hyva-Themes basieren auf serverseitig gerendertem HTML aus phtml-Templates, angereichert mit Alpine.js fuer Interaktivitaet direkt im Markup ueber x-data, x-show und aehnliche Direktiven. Es gibt keinen Client-seitigen Router und damit auch keine Routen, entlang derer man Code aufteilen koennte. Route-basiertes Splitting, wie es in SPA-Frameworks Standard ist, ist in diesem Architekturmodell schlicht nicht anwendbar, weil jede Seite bereits als vollstaendiges HTML-Dokument vom Server kommt.

Was uebrig bleibt und tatsaechlich Wirkung zeigt, ist eng begrenztes, komponentenbasiertes Splitting fuer einzelne schwere interaktive Widgets: eine Bildzoom-Funktion in der Produktgalerie, ein Groessenberater-Modal, ein Live-Chat-Einstiegspunkt. Diese Widgets werden als eigenstaendiges Alpine-Komponentenmodul ausgelagert und erst beim x-init oder bei der ersten Nutzerinteraktion per import() nachgeladen, bevor Alpine.data() die Komponente registriert. Der Rest der Seite bleibt server-gerendertes HTML mit minimalem Alpine-Kern, der ohnehin schon deutlich kleiner ist als ein typisches SPA-Framework-Bundle.


<!-- phtml template: size guide modal loaded only on first open -->
<div x-data="sizeGuideModal()" x-init="init()">
    <button
        type="button"
        @click="open()"
        class="text-sm underline"
    >
        Groessentabelle anzeigen
    </button>

    <template x-if="loaded && isOpen">
        <div class="fixed inset-0 z-50" x-html="modalContent"></div>
    </template>
</div>

<script>
function sizeGuideModal() {
    return {
        isOpen: false,
        loaded: false,
        modalContent: '',
        init() {
            // No import here yet - wait for the actual interaction
        },
        async open() {
            if (!this.loaded) {
                // Dynamically import the heavy modal module on first click
                const { renderSizeGuide } = await import('./components/size-guide.js');
                this.modalContent = await renderSizeGuide();
                this.loaded = true;
            }
            this.isOpen = true;
        },
    };
}
</script>

8. Preloading und Prefetching gezielt einsetzen

Code-Splitting allein loest nur die Frage, wann Code ausgefuehrt wird, nicht wann er heruntergeladen wird. Ohne zusaetzliche Steuerung passiert der Download exakt im Moment des Bedarfs, was den Nutzer eine wahrnehmbare Verzoegerung spueren laesst. rel="modulepreload" im Head signalisiert dem Browser, ein Modul samt seiner Abhaengigkeiten frueh zu laden und zu parsen, aber noch nicht auszufuehren, sodass es bei tatsaechlichem Bedarf sofort verfuegbar ist statt einen kompletten Netzwerk-Roundtrip zu erfordern.

Fuer Widgets, die wahrscheinlich, aber nicht sicher gebraucht werden, eignet sich requestIdleCallback: Sobald der Main Thread nach dem initialen Render frei ist, koennen wahrscheinlich benoetigte Chunks im Hintergrund vorab geladen werden, ohne die kritische Ladephase zu stoeren. Eine weitere praxiserprobte Technik ist Hover-Prefetch: Bei mouseenter auf einem Button, der ein Modal oeffnet, wird der zugehoerige Chunk bereits geladen, noch bevor der eigentliche Klick erfolgt. Die menschliche Reaktionszeit zwischen Hover und Klick, typischerweise 100 bis 300 Millisekunden, reicht oft aus, um den Netzwerk-Request unsichtbar im Hintergrund abzuschliessen.


<!-- Preload a module chunk that will very likely run soon -->
<link rel="modulepreload" href="/static/js/chunks/size-guide.a1b2c3.js">

<script>
// Prefetch on hover, before the actual click happens
const trigger = document.getElementById('size-guide-trigger');
let prefetched = false;

trigger.addEventListener('mouseenter', () => {
    if (!prefetched) {
        prefetched = true;
        // Human reaction time between hover and click hides this request
        import('./components/size-guide.js');
    }
}, { once: true });

// Idle-time prefetch for widgets likely needed soon after initial render
if ('requestIdleCallback' in window) {
    requestIdleCallback(() => {
        import('./widgets/reviews.js');
    }, { timeout: 2000 });
}
</script>

9. Messen und validieren

Code-Splitting einzufuehren, ohne den Effekt zu messen, fuehrt regelmaessig zu falschen Annahmen. Ein Bundle-Analyzer wie webpack-bundle-analyzer oder das eingebaute Visualisierungs-Tool von Vite zeigt als Treemap, welche Module in welchem Chunk landen, und deckt dabei ein haeufiges Problem auf: Dieselbe Bibliothek wird versehentlich in mehreren Chunks dupliziert, statt korrekt in einen gemeinsamen Vendor-Chunk zu wandern. Ohne diese Visualisierung bleibt so ein Duplizierungsfehler oft monatelang unentdeckt.

Der zweite unverzichtbare Check laeuft in den Chrome DevTools ueber den Tab Coverage. Er zeigt pro geladener Datei den Anteil an Code, der beim aktuellen Seitenaufruf tatsaechlich ausgefuehrt wurde, farblich markiert in Rot fuer ungenutzten Code. Bestaetigt dieser Tab, dass ein ausgelagerter Chunk beim initialen Laden gar nicht erst angefragt wird, ist der Split korrekt wirksam. Zusaetzlich lohnt sich ein Blick ins Network-Panel: Der Zeitpunkt der Chunk-Anfrage sollte klar mit dem ausloesenden Ereignis korrelieren, etwa dem Scroll-Zeitpunkt oder dem Klick, und nicht bereits beim initialen Seitenaufbau erfolgen.

Ansatz Initiales Bundle Zusaetzliche Requests Geeignet fuer
Kein Splitting Alles in einem Bundle Keine Sehr kleine Seiten mit wenig JS
Route-basiert Mittel, nur aktive Route 1 pro Navigation SPA-Frameworks mit Client-Routing
Komponentenbasiert Klein, nur Kern-UI 1 pro genutztem Widget Server-gerenderte Seiten wie Hyva
Vendor-Chunk Mittel, aber besser cachebar 1 zusaetzlich, meist gecached Apps mit stabilen Dependencies
On-Interaction Lazy Load Minimal 1 pro Interaktion, verzoegert Schwere Widgets unterhalb des Folds

Mironsoft

Bundle-Analyse, Code-Splitting und Frontend-Performance fuer Magento und Hyva

Schwere JavaScript-Bundles gezielt verkleinern?

Wir analysieren eure Bundle-Struktur, identifizieren unnoetig fruehe geladene Widgets und implementieren dynamic import(), Vendor-Chunk-Splitting und IntersectionObserver-basiertes Lazy Loading, abgestimmt auf Hyva und Alpine.js.

Bundle-Audit

Bundle-Analyzer und Coverage-Auswertung zur Priorisierung nach Impact

Widget-Splitting

Alpine-Komponenten fuer Modals, Galerien und Reviews gezielt auslagern

Build-Konfiguration

Vendor-Chunks, Preload-Hints und Prefetch-Strategien im Build-Setup

10. Zusammenfassung

Code-Splitting und Lazy Loading loesen ein konkretes Problem: Nicht sofort benoetigter JavaScript-Code soll auch nicht sofort geladen und ausgefuehrt werden. Dynamic import() ist dabei der technische Grundbaustein, den Bundler automatisch in separate Chunks uebersetzen. Vendor-Chunk-Splitting trennt stabilen Drittanbieter-Code vom sich haeufig aendernden Anwendungscode und verbessert dadurch die Cache-Effizienz ueber Deployments hinweg. IntersectionObserver-basiertes Lazy Loading verschiebt schwere Widgets wie Reviews- oder Galerie-Komponenten auf den Moment, in dem sie tatsaechlich sichtbar werden.

In server-gerenderten Hyva-Shops ist Route-basiertes Splitting kein anwendbares Konzept, weil es keine Client-Routen gibt. Wirkung entfaltet hier ausschliesslich komponentenbasiertes Splitting fuer einzelne schwere Interaktionswidgets wie Modals, Bildzoom oder Live-Chat, kombiniert mit gezieltem Preloading und Hover-Prefetch, um die Verzoegerung durch den Nachladevorgang unsichtbar zu machen. Messbarkeit ueber Bundle-Analyzer und die Coverage-Ansicht der DevTools stellt sicher, dass jede Splitting-Entscheidung tatsaechlich zu weniger Code im kritischen Pfad fuehrt, statt nur die Chunk-Anzahl zu erhoehen.

Code-Splitting und Lazy Loading - Das Wichtigste auf einen Blick

dynamic import()

Gibt ein Promise zurueck, Bundler erzeugen automatisch einen separaten Chunk fuer jeden Aufruf.

Vendor-Chunks

Stabile Drittanbieter-Bibliotheken separat cachen, App-Code unabhaengig davon aktualisieren.

Below-the-fold Lazy Load

IntersectionObserver mit rootMargin laedt Widgets kurz bevor sie sichtbar werden.

Hyva/Alpine.js

Kein Route-Splitting moeglich, nur gezieltes Splitting einzelner schwerer Widgets.

11. FAQ: Code-Splitting und Lazy Loading

1Was ist der Unterschied zwischen statischem und dynamischem import()?
Statische Imports landen im selben Bundle. Dynamisches import() gibt ein Promise zurueck, Bundler erzeugen automatisch einen separaten Chunk dafuer.
2Warum ist Bundle-Groesse mehr als nur ein Download-Problem?
Parsen und Kompilieren laufen auf dem Main Thread und blockieren Rendering und Interaktion, unabhaengig von der Downloadzeit.
3Route-basiertes vs komponentenbasiertes Splitting?
Route-basiert splittet entlang der Client-Routen einer SPA, komponentenbasiert lagert einzelne Widgets unabhaengig von der Route aus.
4Was bringt Vendor-Chunk-Splitting konkret?
Trennt stabile Drittanbieter-Bibliotheken vom App-Code, sodass nur der kleinere App-Chunk bei neuen Deployments neu geladen werden muss.
5Wie funktioniert IntersectionObserver-basiertes Lazy Loading?
Der Observer beobachtet ein Element und loest einen dynamic import() aus, sobald es sich dem Viewport naehert oder eintritt.
6Verschlechtert Code-Splitting die Performance durch mehr Requests?
HTTP/2-Multiplexing mildert das Problem. Zu granulares Splitting schadet dennoch, Chunks sollten pro Feature gebildet werden, nicht pro Funktion.
7Warum funktioniert Route-basiertes Splitting nicht in Hyva-Shops?
Hyva rendert Seiten serverseitig als vollstaendiges HTML ohne Client-Router, damit fehlt die Grenze fuer Route-basiertes Splitting.
8Wie wird ein Alpine.js-Widget dynamisch nachgeladen?
Per import() beim x-init oder bei der ersten Interaktion, vor der Registrierung mit Alpine.data().
9Was macht rel=modulepreload und wann setzt man es ein?
Laedt und parst ein Modul frueh, ohne es auszufuehren. Sinnvoll fuer Chunks, die mit hoher Wahrscheinlichkeit bald gebraucht werden.
10Wie pruefe ich, ob ein Chunk wirklich lazy geladen wird?
Coverage-Tab fuer genutzten Code pro Datei, Network-Panel um zu pruefen, dass die Anfrage erst beim ausloesenden Ereignis erfolgt.