Eine React-App beginnt klein: ein paar Komponenten, wenige Abhängigkeiten. Sechs Monate später hat der Bundle 500KB JavaScript, lädt drei Sekunden auf mobilem 3G, und die Hälfte des Codes wird beim ersten Aufruf gar nicht gebraucht. Das Admin-Dashboard? Nur für 5% der Nutzer relevant. Der komplexe Chart-Editor? Braucht man erst nach Login. Trotzdem lädt jeder Besucher alles.
Code-Splitting löst dieses Problem: Statt ein monolithisches Bundle
zu bauen, teilt man die App in kleinere Chunks auf, die on-demand
geladen werden. React bietet mit React.lazy() und
Suspense eingebaute Mechanismen dafür.
Moderne React-Apps bestehen aus hunderten Komponenten und dutzenden Bibliotheken. Webpack oder Vite bündeln alles zu JavaScript-Dateien, die der Browser laden muss, bevor die App interaktiv wird.
# Typisches Production Build ohne Code-Splitting
dist/
index.html
assets/
main-a8f3d2b1.js # 847 KB ← Problem: Alles in einer Datei
main-c4e9f7a2.css # 123 KBMetriken: - First Contentful Paint (FCP): Wann erscheint erstes Content - Time to Interactive (TTI): Wann kann User interagieren - Total Blocking Time (TBT): Wie lange ist Main Thread blockiert
Große Bundles verzögern alle drei Metriken. Ein 800KB Bundle braucht: - ~2-3 Sekunden Download auf 3G - ~200-400ms Parse & Compile - Main Thread ist während dessen blockiert
React.lazy() ermöglicht es, Komponenten erst zu laden,
wenn sie gebraucht werden.
// ❌ Statischer Import: Im Haupt-Bundle
import Dashboard from './pages/Dashboard';
import Settings from './pages/Settings';
import Analytics from './pages/Analytics';
function App() {
return (
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
);
}
// ✓ Dynamischer Import: Separate Chunks
import { lazy } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));
function App() {
return (
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
);
}
Build-Output mit Code-Splitting:
dist/
index.html
assets/
main-a8f3d2b1.js # 142 KB ← Kleiner Haupt-Bundle
Dashboard-b9e4f1c3.js # 287 KB ← Lazy-loaded Chunk
Settings-d7a2e8f5.js # 98 KB ← Lazy-loaded Chunk
Analytics-e3c9d4a6.js # 320 KB ← Lazy-loaded Chunk
main-c4e9f7a2.css # 123 KBDer Browser lädt nur den Main-Chunk beim ersten Aufruf.
Dashboard-Chunk wird erst geladen, wenn User zu /dashboard
navigiert.
lazy() allein reicht nicht – React braucht einen
Fallback, der angezeigt wird, während der Chunk lädt. Dafür gibt es
Suspense.
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Dashboard />
</Suspense>
);
}
Wenn <Dashboard /> zum ersten Mal gerendert wird:
1. React startet den Import von ./pages/Dashboard 2.
Während des Ladens zeigt Suspense den Fallback
(<div>Loading...</div>) 3. Sobald der Chunk
geladen ist, rendert React die echte Komponente
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
);
}
Alle drei Routes nutzen dieselbe Suspense-Boundary. Egal welche Route
lädt, der Fallback ist <LoadingSpinner />.
function App() {
return (
<Suspense fallback={<PageLoader />}>
<Layout>
<Suspense fallback={<SidebarLoader />}>
<Sidebar />
</Suspense>
<main>
<Suspense fallback={<ContentLoader />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</main>
</Layout>
</Suspense>
);
}
Hierarchie: - Outer Suspense: Fallback für
<Layout> (falls lazy) - Sidebar Suspense: Nur Sidebar
zeigt Loader - Main Suspense: Nur Main-Content zeigt Loader
React nimmt immer die nächste Suspense-Boundary nach oben.
Der häufigste Use Case: Jede Route als separater Chunk.
// src/App.tsx
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// Eager-loaded: Immer im Main Bundle
import Layout from './components/Layout';
import Home from './pages/Home';
// Lazy-loaded: Separate Chunks
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Products = lazy(() => import('./pages/Products'));
const ProductDetail = lazy(() => import('./pages/ProductDetail'));
const Settings = lazy(() => import('./pages/Settings'));
const Admin = lazy(() => import('./pages/Admin'));
function App() {
return (
<BrowserRouter>
<Layout>
<Suspense fallback={<PageLoadingSpinner />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/products" element={<Products />} />
<Route path="/products/:id" element={<ProductDetail />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<Admin />} />
</Routes>
</Suspense>
</Layout>
</BrowserRouter>
);
}
export default App;
Warum Home nicht lazy?
Die Landing-Page sollte sofort verfügbar sein. First Contentful Paint
wäre langsamer, wenn auch Home lazy-loaded wäre.
Bundle-Strategie:
| Chunk | Inhalt | Wann laden |
|---|---|---|
| main.js | Home, Layout, Router | Initial |
| Dashboard.js | Dashboard + Dependencies | Bei /dashboard |
| Products.js | Products-Liste | Bei /products |
| ProductDetail.js | Detail-Ansicht | Bei /products/:id |
| Settings.js | Settings + Form-Libraries | Bei /settings |
| Admin.js | Admin-Panel + Table-Libraries | Bei /admin |
React.lazy() funktioniert nur mit default
exports.
// ❌ Falsch: Named Export
// Dashboard.tsx
export function Dashboard() {
return <div>Dashboard</div>;
}
// App.tsx
const Dashboard = lazy(() => import('./pages/Dashboard'));
// Error: Module has no default export
// ✓ Richtig: Default Export
// Dashboard.tsx
export default function Dashboard() {
return <div>Dashboard</div>;
}
// App.tsx
const Dashboard = lazy(() => import('./pages/Dashboard'));
Wenn du Named Exports bevorzugst, brauchst du einen Wrapper:
// Dashboard.tsx (Named Export)
export function Dashboard() {
return <div>Dashboard</div>;
}
// App.tsx
const Dashboard = lazy(() =>
import('./pages/Dashboard').then(module => ({ default: module.Dashboard }))
);
Ein generisches “Loading…” ist langweilig. Bessere UX mit kontextspezifischen Loadern:
// components/LoadingFallbacks.tsx
export function PageLoader() {
return (
<div className="page-loader">
<div className="spinner" />
<p>Loading page...</p>
</div>
);
}
export function DashboardLoader() {
return (
<div className="dashboard-skeleton">
<div className="skeleton-header" />
<div className="skeleton-cards">
<div className="skeleton-card" />
<div className="skeleton-card" />
<div className="skeleton-card" />
</div>
</div>
);
}
export function TableLoader() {
return (
<div className="table-skeleton">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="skeleton-row" />
))}
</div>
);
}
// App.tsx
const Dashboard = lazy(() => import('./pages/Dashboard'));
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
<Suspense fallback={<DashboardLoader />}>
<Dashboard />
</Suspense>
<Suspense fallback={<TableLoader />}>
<AdminPanel />
</Suspense>
Skeleton Screens sind besser als Spinner – sie zeigen die Struktur der kommenden Seite.
Netzwerk-Fehler, Server-Timeouts, oder gelöschte Chunks führen zu Lade-Fehlern. Error Boundaries fangen diese ab.
// components/LazyErrorBoundary.tsx
import { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class LazyErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error) {
console.error('Lazy loading failed:', error);
}
retry = () => {
this.setState({ hasError: false, error: null });
window.location.reload();
};
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="lazy-error">
<h2>Failed to load component</h2>
<p>{this.state.error?.message}</p>
<button onClick={this.retry}>Retry</button>
</div>
);
}
return this.props.children;
}
}
// App.tsx
function App() {
return (
<LazyErrorBoundary>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>
</LazyErrorBoundary>
);
}
Kombination: Error Boundary außen, Suspense innen.
Nicht nur Routes – auch einzelne Features können lazy sein.
// Heavy Chart Library nur laden wenn Chart wirklich angezeigt wird
import { lazy, Suspense, useState } from 'react';
const HeavyChart = lazy(() => import('./components/HeavyChart'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<h1>Dashboard</h1>
<button onClick={() => setShowChart(true)}>
Show Advanced Analytics
</button>
{showChart && (
<Suspense fallback={<div>Loading chart...</div>}>
<HeavyChart />
</Suspense>
)}
</div>
);
}
Chart-Chunk wird erst geladen, wenn User auf Button klickt – nicht beim Laden der Dashboard-Page.
Weitere Use Cases: - Modal-Dialoge (erst bei Öffnung laden) - Tabs (erst beim Wechsel zum Tab) - Accordion-Sections (beim Aufklappen) - Admin-Features (nur für Admins)
// Modal erst bei Bedarf laden
function ProductList() {
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
return (
<div>
{products.map(product => (
<ProductCard
key={product.id}
product={product}
onEdit={() => setEditingProduct(product)}
/>
))}
{editingProduct && (
<Suspense fallback={<ModalSkeleton />}>
<EditProductModal
product={editingProduct}
onClose={() => setEditingProduct(null)}
/>
</Suspense>
)}
</div>
);
}
const EditProductModal = lazy(() => import('./EditProductModal'));
Problem: User klickt auf “Dashboard”, dann wartet er 2 Sekunden bis Chunk lädt. Besser: Chunk vorher laden, wenn absehbar ist, dass er bald gebraucht wird.
// Dashboard.tsx
import { lazy } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
// Preload-Funktion exportieren
export const preloadDashboard = () => {
import('./pages/Dashboard');
};
// In Navigation
function Navigation() {
return (
<nav>
<Link
to="/dashboard"
onMouseEnter={preloadDashboard} // Beim Hover schon laden
>
Dashboard
</Link>
</nav>
);
}
User hovert über Link → Chunk lädt im Hintergrund → User klickt → Chunk ist schon da.
Strategien:
| Strategie | Wann | Trade-off |
|---|---|---|
| On Hover | onMouseEnter |
Lädt vielleicht unnötig |
| On Focus | onFocus (Keyboard) |
Accessibility-Support |
| Idle Time | requestIdleCallback |
Nutzt freie Browser-Zeit |
| Route Prefetch | Beim Laden der Seite | Mehr initiales Datenvolumen |
// Idle-Time Preloading
import { useEffect } from 'react';
function App() {
useEffect(() => {
// Preload non-critical routes während Idle
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
import('./pages/Settings');
import('./pages/Profile');
});
} else {
// Fallback: Nach 2 Sekunden
setTimeout(() => {
import('./pages/Settings');
import('./pages/Profile');
}, 2000);
}
}, []);
return <div>{/* App */}</div>;
}
Manche Libraries sind riesig und ändern sich selten. Diese sollten in separate Chunks.
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
// React Core in separaten Chunk
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
// Heavy Libraries separat
'chart-vendor': ['recharts', 'd3'],
'form-vendor': ['react-hook-form', 'zod'],
// UI-Library
'ui-vendor': ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu']
}
}
}
}
});Build-Output:
dist/assets/
main-a8f3d2b1.js # 45 KB ← App-Code
react-vendor-b9e4f1c3.js # 142 KB ← React (cached!)
chart-vendor-c7d2e8a4.js # 287 KB ← Charts (nur wenn gebraucht)
form-vendor-d3a9f5b6.js # 98 KB ← Forms (nur wenn gebraucht)Vorteil: React-Vendor-Chunk ändert sich selten → Browser-Cache bleibt gültig bei App-Updates.
React.lazy() ist nur für Komponenten. Für andere
Imports: Dynamischer Import direkt.
// Heavy Utility Library lazy laden
function ImageEditor() {
const [editor, setEditor] = useState<any>(null);
useEffect(() => {
// ImageMagick-Wrapper ist 300KB
import('imageMagick-wasm').then(module => {
setEditor(module.default);
});
}, []);
if (!editor) {
return <div>Loading image editor...</div>;
}
return <ImageEditorUI editor={editor} />;
}
// Markdown-Parser nur laden wenn Markdown angezeigt wird
function MarkdownPreview({ content }: { content: string }) {
const [html, setHtml] = useState<string>('');
useEffect(() => {
import('marked').then(({ marked }) => {
setHtml(marked.parse(content));
});
}, [content]);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
React 18+ unterstützt Suspense für Data Fetching (noch nicht stabil, aber kommend).
// Mit React Query + Suspense
import { useSuspenseQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: number }) {
// useSuspenseQuery wirft Promise → Suspense fängt ab
const { data } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId)
});
return <div>{data.name}</div>;
}
// Verwendung
function App() {
return (
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userId={123} />
</Suspense>
);
}
Während fetchUser läuft, zeigt Suspense den Fallback.
Sobald Daten da sind, rendert <UserProfile>.
Fehler 1: Suspense vergessen
// ❌ Falsch: Lazy ohne Suspense
const Dashboard = lazy(() => import('./Dashboard'));
function App() {
return <Dashboard />; // Error: Suspense boundary not found
}
// ✓ Richtig: Suspense wrappen
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Dashboard />
</Suspense>
);
}
Fehler 2: Conditional Lazy
// ❌ Falsch: lazy() in if-Statement
function App() {
if (condition) {
const Dashboard = lazy(() => import('./Dashboard')); // Hooks-Regel verletzt!
}
}
// ✓ Richtig: lazy() auf Top-Level
const Dashboard = lazy(() => import('./Dashboard'));
function App() {
if (condition) {
return <Dashboard />;
}
}
Fehler 3: Named Export ohne Wrapper
// ❌ Falsch: Named Export direkt
// Dashboard.tsx
export function Dashboard() { /*...*/ }
// App.tsx
const Dashboard = lazy(() => import('./Dashboard')); // Error!
// ✓ Richtig: Default Export oder Wrapper
const Dashboard = lazy(() =>
import('./Dashboard').then(m => ({ default: m.Dashboard }))
);
Fehler 4: Über-Splitting
// ❌ Falsch: Zu viele kleine Chunks
const Button = lazy(() => import('./Button'));
const Input = lazy(() => import('./Input'));
const Label = lazy(() => import('./Label'));
// Overhead übersteigt Benefit!
// ✓ Richtig: Sinnvolle Chunk-Größen
// Kleine UI-Components im Main Bundle
// Nur große Features/Routes lazy-loaden
Fehler 5: Keine Error Boundary
// ❌ Falsch: Kein Error Handling
<Suspense fallback={<Loader />}>
<LazyComponent />
</Suspense>
// Was wenn Chunk-Download fehlschlägt? → Weiße Seite
// ✓ Richtig: Error Boundary drumherum
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<LazyComponent />
</Suspense>
</ErrorBoundary>
Ohne Code-Splitting:
Initial Bundle: 847 KB
FCP: 3.2s
TTI: 4.1s
Lighthouse Score: 62
Mit Route-Based Splitting:
Initial Bundle: 142 KB
Route Chunks: 98KB - 320KB (lazy loaded)
FCP: 1.1s
TTI: 1.8s
Lighthouse Score: 94
Gewinn: - -83% Initial Bundle Size - -66% FCP - -56% TTI - +52% Lighthouse Score
| Kandidat | Lazy Loading? | Begründung |
|---|---|---|
| Landing Page | ❌ Nein | FCP critical |
| Dashboard (nach Login) | ✅ Ja | Nicht initial sichtbar |
| Admin Panel | ✅ Ja | Nur wenige User |
| Settings | ✅ Ja | Selten besucht |
| Charts/Analytics | ✅ Ja | Heavy dependencies |
| Modal-Dialoge | ✅ Ja | Nur bei Interaktion |
| UI-Components (Button, Input) | ❌ Nein | Zu klein, Overhead nicht wert |
| Auth-Forms | ⚠️ Kontext | Wenn nach Landing-Page |
| Error Pages | ❌ Nein | Müssen sofort verfügbar sein |
Code-Splitting mit React.lazy() und
Suspense ist keine Optimierung für später – es ist Standard
für produktionsreife React-Apps. Die initiale Investment in
Loading-States und Error-Boundaries zahlt sich aus bei jedem User, der
nicht drei Sekunden auf ein 800KB Bundle warten muss.