39 Suspense und Lazy Loading – Code-Splitting für Performance

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.

39.1 Das Problem: Bundle-Größe und Time-to-Interactive

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 KB

Metriken: - 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

39.2 React.lazy: Dynamischer Component Import

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 KB

Der Browser lädt nur den Main-Chunk beim ersten Aufruf. Dashboard-Chunk wird erst geladen, wenn User zu /dashboard navigiert.

39.3 Suspense: Loading-Boundaries definieren

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

39.3.1 Mehrere Komponenten unter einer Suspense-Boundary

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 />.

39.3.2 Nested Suspense: Granulare Loading-States

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.

39.4 Route-Based Code-Splitting: Der Standard-Ansatz

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

39.5 TypeScript: Named Exports und Default Exports

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 }))
);

39.6 Custom Loading Components

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.

39.7 Error Boundaries: Lazy Loading kann fehlschlagen

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.

39.8 Component-Based Code-Splitting: Features lazy-loaden

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'));

39.9 Preloading: Chunks vor Bedarf laden

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>;
}

39.10 Library Code-Splitting: Vendor-Chunks optimieren

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.

39.11 Dynamic Imports für Non-Components

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 }} />;
}

39.12 Suspense mit Data Fetching (Experimental)

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>.

39.13 Häufige Fehler

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>

39.14 Performance-Metriken: Vorher/Nachher

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

39.15 Zusammenfassung: Wann was lazy-loaden?

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.