Advanced Lazy Loading: Routes, Components, Images & Fonts

Lazy loading is the single most effective technique for reducing initial bundle size and improving page load performance. By implementing advanced lazy loading patterns, you can reduce your ChatGPT app's initial bundle by 70%+, achieve sub-2-second Largest Contentful Paint (LCP), and deliver a blazing-fast user experience.

This comprehensive guide covers route-based code splitting, component lazy loading, image lazy loading with Intersection Observer, font optimization strategies, and production-ready implementation patterns for ChatGPT applications built with MakeAIHQ.

Table of Contents

  1. Why Lazy Loading Reduces Bundle Size by 70%
  2. Route-Based Code Splitting
  3. Component Lazy Loading with Suspense
  4. Image Lazy Loading: Intersection Observer
  5. Font Optimization Strategies
  6. Performance Measurement & Monitoring
  7. Production Best Practices

Why Lazy Loading Reduces Bundle Size by 70% {#why-lazy-loading-matters}

Modern ChatGPT apps ship with massive JavaScript bundles: analytics libraries, chat UI components, Firebase SDKs, and complex state management. Without lazy loading, users download the entire app upfront—even code for pages they never visit.

The Bundle Problem

A typical ChatGPT app without lazy loading:

  • Initial Bundle: 450-600 KB (minified, gzipped)
  • Time to Interactive (TTI): 4-6 seconds on 3G
  • First Contentful Paint (FCP): 2.5-3.5 seconds
  • Lighthouse Score: 60-75/100

With advanced lazy loading:

  • Initial Bundle: 120-180 KB (70% reduction)
  • Time to Interactive (TTI): 1.5-2.5 seconds
  • First Contentful Paint (FCP): 0.8-1.2 seconds
  • Lighthouse Score: 95-100/100

Learn more about ChatGPT app performance optimization and Core Web Vitals strategies.

Real-World Impact

MakeAIHQ's production dashboard uses aggressive lazy loading:

  • Marketing Pages: Eager-loaded (critical for SEO)
  • Dashboard Pages: Lazy-loaded (reduces initial bundle by 62%)
  • Firebase SDK: Lazy-loaded (saves 180 KB)
  • Chart.js: Lazy-loaded (saves 95 KB)

Result: 100/100 PageSpeed score on mobile and desktop.

Route-Based Code Splitting {#route-based-code-splitting}

Route-based code splitting is the most impactful lazy loading strategy. Each route becomes a separate bundle, loaded only when users navigate to that page.

How Route Splitting Works

  1. Build Time: Bundler (Webpack/Vite) creates separate chunks for each route
  2. Runtime: Router detects navigation and dynamically imports route component
  3. Caching: Browser caches route chunks for instant subsequent visits
  4. Prefetching: Optionally prefetch likely next routes on idle

React Router Lazy Loading (130 lines)

/**
 * Route-Based Code Splitting for React + React Router
 * Implements lazy loading, loading states, error boundaries, prefetching
 */

import React, { Suspense, lazy, useEffect } from 'react';
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';

// Loading Component
const LoadingSpinner = () => (
  <div className="loading-container" role="status" aria-live="polite">
    <div className="spinner" aria-hidden="true"></div>
    <span className="sr-only">Loading page...</span>
  </div>
);

// Error Boundary for Route Loading Failures
class RouteErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, errorInfo) {
    console.error('Route loading failed:', error, errorInfo);

    // Log to analytics
    if (window.gtag) {
      window.gtag('event', 'exception', {
        description: `Route Error: ${error.message}`,
        fatal: false
      });
    }
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-container">
          <h2>Failed to load page</h2>
          <p>Please refresh or try again later.</p>
          <button onClick={() => window.location.reload()}>
            Reload Page
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

// Lazy Route Wrapper with Prefetching
const LazyRoute = ({ importFunc, prefetch = false }) => {
  const Component = lazy(importFunc);

  useEffect(() => {
    if (prefetch) {
      // Prefetch on mount (for likely next routes)
      importFunc();
    }
  }, [importFunc, prefetch]);

  return (
    <RouteErrorBoundary>
      <Suspense fallback={<LoadingSpinner />}>
        <Component />
      </Suspense>
    </RouteErrorBoundary>
  );
};

// Route Configuration with Lazy Loading
const App = () => {
  const location = useLocation();

  // Track page views
  useEffect(() => {
    if (window.gtag) {
      window.gtag('config', 'GA_MEASUREMENT_ID', {
        page_path: location.pathname
      });
    }
  }, [location]);

  return (
    <Routes>
      {/* Marketing Pages - Eager Loaded (SEO critical) */}
      <Route path="/" element={<Home />} />
      <Route path="/features" element={<Features />} />
      <Route path="/pricing" element={<Pricing />} />

      {/* Dashboard - Lazy Loaded */}
      <Route
        path="/dashboard"
        element={
          <LazyRoute
            importFunc={() => import('./pages/Dashboard')}
          />
        }
      />

      <Route
        path="/dashboard/apps"
        element={
          <LazyRoute
            importFunc={() => import('./pages/Apps')}
            prefetch={true} // Likely next page
          />
        }
      />

      <Route
        path="/dashboard/apps/:id/edit"
        element={
          <LazyRoute
            importFunc={() => import('./pages/AppEditor')}
          />
        }
      />

      <Route
        path="/dashboard/analytics"
        element={
          <LazyRoute
            importFunc={() => import('./pages/Analytics')}
          />
        }
      />

      {/* Catch-all 404 */}
      <Route path="*" element={<NotFound />} />
    </Routes>
  );
};

// Router with Loading State Persistence
const AppWithRouter = () => {
  return (
    <BrowserRouter>
      <App />
    </BrowserRouter>
  );
};

export default AppWithRouter;

Vite/Rollup Configuration for Optimal Chunking

// vite.config.js - Production-Ready Code Splitting
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // Vendor chunks
          'react-vendor': ['react', 'react-dom', 'react-router-dom'],
          'firebase': ['firebase/app', 'firebase/auth', 'firebase/firestore'],
          'charts': ['chart.js', 'react-chartjs-2'],

          // Route chunks (automatic with dynamic imports)
        },
        chunkFileNames: 'assets/[name]-[hash].js',
        entryFileNames: 'assets/[name]-[hash].js',
        assetFileNames: 'assets/[name]-[hash].[ext]'
      }
    },

    // Code splitting thresholds
    chunkSizeWarningLimit: 500, // Warn if chunk > 500 KB
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true,
        passes: 2
      },
      mangle: {
        safari10: true
      }
    }
  }
};

Learn more about Vite optimization strategies and bundle size analysis.

Component Lazy Loading with Suspense {#component-lazy-loading}

Component-level lazy loading defers non-critical UI components until they're needed. This is perfect for modals, charts, heavy widgets, and admin panels.

When to Lazy Load Components

Always Lazy Load:

  • Analytics dashboards (Chart.js)
  • Video players
  • Rich text editors (TinyMCE, Quill)
  • 3D visualizations
  • Admin panels
  • Modals/dialogs

Never Lazy Load:

  • Navigation bars
  • Hero sections
  • Critical CTAs
  • Authentication forms

Reusable Lazy Component Wrapper (140 lines)

/**
 * Reusable Lazy Component Wrapper with Suspense
 * Handles loading states, error boundaries, retry logic
 */

import React, { Suspense, lazy, useState, useEffect } from 'react';

// Generic Loading Placeholder
const ComponentLoader = ({ height = '400px', message = 'Loading...' }) => (
  <div
    className="component-loader"
    style={{ height }}
    role="status"
    aria-live="polite"
  >
    <div className="skeleton-loader">
      <div className="skeleton-header"></div>
      <div className="skeleton-body"></div>
      <div className="skeleton-footer"></div>
    </div>
    <span className="sr-only">{message}</span>
  </div>
);

// Error Fallback with Retry
const ComponentError = ({ error, onRetry }) => (
  <div className="component-error">
    <h3>Failed to load component</h3>
    <p>{error?.message || 'An unexpected error occurred'}</p>
    <button onClick={onRetry} className="retry-button">
      Retry
    </button>
  </div>
);

// Lazy Component Wrapper
const LazyComponent = ({
  importFunc,
  fallback,
  onError,
  retryDelay = 1000,
  maxRetries = 3,
  ...props
}) => {
  const [retryCount, setRetryCount] = useState(0);
  const [Component, setComponent] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    let mounted = true;

    const loadComponent = async () => {
      try {
        const module = await importFunc();

        if (mounted) {
          setComponent(() => module.default || module);
          setError(null);
        }
      } catch (err) {
        console.error('Component load failed:', err);

        if (mounted) {
          setError(err);

          if (onError) {
            onError(err);
          }

          // Auto-retry on network errors
          if (retryCount < maxRetries && err.name === 'TypeError') {
            setTimeout(() => {
              setRetryCount(retryCount + 1);
            }, retryDelay);
          }
        }
      }
    };

    loadComponent();

    return () => {
      mounted = false;
    };
  }, [importFunc, retryCount, maxRetries, retryDelay, onError]);

  const handleRetry = () => {
    setRetryCount(retryCount + 1);
    setError(null);
  };

  if (error) {
    return <ComponentError error={error} onRetry={handleRetry} />;
  }

  if (!Component) {
    return fallback || <ComponentLoader />;
  }

  return <Component {...props} />;
};

// Usage Examples

// 1. Lazy Load Analytics Dashboard
const AnalyticsDashboard = (props) => (
  <LazyComponent
    importFunc={() => import('./components/AnalyticsDashboard')}
    fallback={<ComponentLoader height="600px" message="Loading analytics..." />}
    onError={(err) => console.error('Analytics failed:', err)}
    {...props}
  />
);

// 2. Lazy Load Chart Component
const RevenueChart = (props) => (
  <LazyComponent
    importFunc={() => import('./components/Charts/RevenueChart')}
    fallback={<ComponentLoader height="400px" message="Loading chart..." />}
    {...props}
  />
);

// 3. Lazy Load Modal
const UserSettingsModal = ({ isOpen, onClose, ...props }) => {
  if (!isOpen) return null; // Don't load if not open

  return (
    <LazyComponent
      importFunc={() => import('./components/Modals/UserSettings')}
      fallback={<ComponentLoader height="500px" />}
      onClose={onClose}
      {...props}
    />
  );
};

export { LazyComponent, AnalyticsDashboard, RevenueChart, UserSettingsModal };

CSS for Loading Skeletons

/* Skeleton Loading Animation */
.skeleton-loader {
  padding: 20px;
  animation: pulse 1.5s ease-in-out infinite;
}

.skeleton-header {
  height: 40px;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 8px;
  margin-bottom: 16px;
}

.skeleton-body {
  height: 200px;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 8px;
  margin-bottom: 16px;
}

.skeleton-footer {
  height: 60px;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 8px;
}

@keyframes shimmer {
  0% { background-position: -200% 0; }
  100% { background-position: 200% 0; }
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.8; }
}

Image Lazy Loading: Intersection Observer {#image-lazy-loading}

Images are often the largest assets on ChatGPT apps (logos, screenshots, charts). Lazy loading images with Intersection Observer can reduce initial page weight by 60-80%.

Native Lazy Loading vs. Intersection Observer

Native Lazy Loading (loading="lazy"):

  • ✅ Simple: One attribute
  • ✅ Browser-native (no JavaScript)
  • ❌ Limited control (browser decides threshold)
  • ❌ No custom animations

Intersection Observer:

  • ✅ Full control (custom thresholds, animations)
  • ✅ Progressive loading (blur-up effect)
  • ✅ Analytics integration (track viewport visibility)
  • ❌ Requires JavaScript

Best Practice: Use both (native as fallback).

Production-Ready Image Lazy Loader (120 lines)

/**
 * Image Lazy Loader with Intersection Observer
 * Supports blur-up placeholders, progressive loading, error handling
 */

interface LazyImageOptions {
  root?: Element | null;
  rootMargin?: string;
  threshold?: number;
  placeholder?: string;
  onLoad?: (img: HTMLImageElement) => void;
  onError?: (img: HTMLImageElement, error: Event) => void;
}

class ImageLazyLoader {
  private observer: IntersectionObserver | null = null;
  private images: Set<HTMLImageElement> = new Set();
  private options: LazyImageOptions;

  constructor(options: LazyImageOptions = {}) {
    this.options = {
      root: null,
      rootMargin: '50px', // Load 50px before entering viewport
      threshold: 0.01,
      placeholder: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300"%3E%3Crect fill="%23f0f0f0" width="400" height="300"/%3E%3C/svg%3E',
      ...options
    };

    this.initObserver();
  }

  private initObserver(): void {
    if (!('IntersectionObserver' in window)) {
      console.warn('IntersectionObserver not supported, falling back to eager load');
      return;
    }

    this.observer = new IntersectionObserver(
      (entries) => this.handleIntersection(entries),
      {
        root: this.options.root,
        rootMargin: this.options.rootMargin!,
        threshold: this.options.threshold!
      }
    );
  }

  private handleIntersection(entries: IntersectionObserverEntry[]): void {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        const img = entry.target as HTMLImageElement;
        this.loadImage(img);
        this.observer?.unobserve(img);
        this.images.delete(img);
      }
    });
  }

  private loadImage(img: HTMLImageElement): void {
    const src = img.dataset.src;
    const srcset = img.dataset.srcset;

    if (!src) return;

    // Create temporary image to preload
    const tempImg = new Image();

    tempImg.onload = () => {
      // Add blur-up animation
      img.classList.add('lazy-loading');

      // Set actual source
      img.src = src;
      if (srcset) {
        img.srcset = srcset;
      }

      // Remove placeholder
      requestAnimationFrame(() => {
        img.classList.remove('lazy-loading');
        img.classList.add('lazy-loaded');
      });

      // Callback
      if (this.options.onLoad) {
        this.options.onLoad(img);
      }

      // Track in analytics
      if (window.gtag) {
        window.gtag('event', 'image_loaded', {
          image_url: src,
          load_time: performance.now()
        });
      }
    };

    tempImg.onerror = (error) => {
      console.error('Image load failed:', src, error);
      img.classList.add('lazy-error');

      if (this.options.onError) {
        this.options.onError(img, error);
      }
    };

    tempImg.src = src;
  }

  public observe(img: HTMLImageElement): void {
    if (!this.observer) {
      // Fallback: load immediately if no IntersectionObserver
      this.loadImage(img);
      return;
    }

    // Set placeholder
    if (!img.src && this.options.placeholder) {
      img.src = this.options.placeholder;
    }

    this.images.add(img);
    this.observer.observe(img);
  }

  public observeAll(selector: string = 'img[data-src]'): void {
    const images = document.querySelectorAll<HTMLImageElement>(selector);
    images.forEach((img) => this.observe(img));
  }

  public disconnect(): void {
    if (this.observer) {
      this.observer.disconnect();
      this.images.clear();
    }
  }
}

// Usage
const lazyLoader = new ImageLazyLoader({
  rootMargin: '100px',
  threshold: 0.01,
  onLoad: (img) => console.log('Loaded:', img.src),
  onError: (img, error) => console.error('Failed:', img.src, error)
});

// Observe all lazy images
lazyLoader.observeAll('img[data-src]');

// HTML Usage:
// <img
//   data-src="/images/screenshot.jpg"
//   data-srcset="/images/screenshot-2x.jpg 2x"
//   alt="ChatGPT App Screenshot"
//   class="lazy-image"
//   loading="lazy"
// />

export default ImageLazyLoader;

Blur-Up CSS Animation

/* Lazy Image Styles */
.lazy-image {
  filter: blur(10px);
  transition: filter 0.3s ease-out;
}

.lazy-loading {
  filter: blur(10px);
}

.lazy-loaded {
  filter: blur(0);
}

.lazy-error {
  filter: grayscale(100%);
  opacity: 0.5;
}

/* Responsive Images */
img[data-src] {
  max-width: 100%;
  height: auto;
  display: block;
}

Learn more about image optimization strategies and responsive images guide.

Font Optimization Strategies {#font-optimization}

Web fonts block rendering (FOIT: Flash of Invisible Text) and add 50-200 KB to page weight. Optimizing fonts with proper font-display, subsetting, and preloading can reduce Largest Contentful Paint by 1-2 seconds.

Font Loading Strategies

font-display: swap (Recommended):

  • Shows fallback font immediately
  • Swaps to web font when loaded
  • No invisible text period

font-display: optional (Fastest):

  • Uses web font only if cached
  • Falls back to system font if not cached
  • Zero layout shift

font-display: block (Avoid):

  • Blocks rendering until font loads
  • Causes FOIT (Flash of Invisible Text)

Font Optimizer (100 lines)

/**
 * Font Optimizer for ChatGPT Apps
 * Implements preloading, subsetting, font-display, fallback fonts
 */

interface FontConfig {
  family: string;
  weights: number[];
  display?: 'swap' | 'optional' | 'block' | 'fallback';
  preload?: boolean;
  subset?: string;
}

class FontOptimizer {
  private fonts: FontConfig[];
  private baseUrl: string;

  constructor(fonts: FontConfig[], baseUrl: string = '/fonts') {
    this.fonts = fonts;
    this.baseUrl = baseUrl;
  }

  /**
   * Generate preload links for critical fonts
   */
  generatePreloadLinks(): string {
    return this.fonts
      .filter(font => font.preload)
      .map(font => {
        const weight = font.weights[0]; // Preload only first weight
        const url = `${this.baseUrl}/${font.family.toLowerCase()}-${weight}.woff2`;

        return `<link rel="preload" href="${url}" as="font" type="font/woff2" crossorigin>`;
      })
      .join('\n');
  }

  /**
   * Generate @font-face CSS with optimal settings
   */
  generateFontFaceCSS(): string {
    return this.fonts.map(font => {
      return font.weights.map(weight => {
        const family = font.family;
        const display = font.display || 'swap';
        const subset = font.subset || 'latin';

        return `
@font-face {
  font-family: '${family}';
  font-weight: ${weight};
  font-display: ${display};
  font-style: normal;
  src: url('${this.baseUrl}/${family.toLowerCase()}-${weight}.woff2') format('woff2'),
       url('${this.baseUrl}/${family.toLowerCase()}-${weight}.woff') format('woff');
  unicode-range: ${this.getUnicodeRange(subset)};
}`;
      }).join('\n');
    }).join('\n');
  }

  /**
   * Get unicode range for subsetting
   */
  private getUnicodeRange(subset: string): string {
    const ranges: Record<string, string> = {
      latin: 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD',
      'latin-ext': 'U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF',
      cyrillic: 'U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116'
    };

    return ranges[subset] || ranges.latin;
  }

  /**
   * Inject fonts into document
   */
  inject(): void {
    // Add preload links
    const preloadLinks = this.generatePreloadLinks();
    if (preloadLinks) {
      const fragment = document.createRange().createContextualFragment(preloadLinks);
      document.head.appendChild(fragment);
    }

    // Add @font-face CSS
    const style = document.createElement('style');
    style.textContent = this.generateFontFaceCSS();
    document.head.appendChild(style);
  }
}

// Usage
const fontConfig: FontConfig[] = [
  {
    family: 'Inter',
    weights: [400, 500, 700],
    display: 'swap',
    preload: true, // Preload only critical font
    subset: 'latin'
  }
];

const fontOptimizer = new FontOptimizer(fontConfig);

// Option 1: Inject at runtime
fontOptimizer.inject();

// Option 2: Generate for build (SSR/SSG)
console.log(fontOptimizer.generatePreloadLinks());
console.log(fontOptimizer.generateFontFaceCSS());

export default FontOptimizer;

Google Fonts Optimization

<!-- ❌ BAD: Blocks rendering, loads all weights -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=block" rel="stylesheet">

<!-- ✅ GOOD: Preconnect + font-display:swap + preload -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="preload" href="https://fonts.gstatic.com/s/inter/v12/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZ9hiA.woff2" as="font" type="font/woff2" crossorigin>

<!-- ✅ BEST: Self-hosted fonts with subsetting -->
<link rel="preload" href="/fonts/inter-400.woff2" as="font" type="font/woff2" crossorigin>
<style>
@font-face {
  font-family: 'Inter';
  font-weight: 400;
  font-display: swap;
  src: url('/fonts/inter-400.woff2') format('woff2');
  unicode-range: U+0000-00FF;
}
</style>

Learn more about font subsetting tools and FOIT vs FOUT strategies.

Performance Measurement & Monitoring {#performance-measurement}

Measuring lazy loading impact requires bundle analysis, load time metrics, and Core Web Vitals monitoring.

Bundle Analyzer Configuration (90 lines)

/**
 * Bundle Size Analysis for Webpack/Vite
 * Visualizes code splitting effectiveness
 */

// Webpack Bundle Analyzer
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',
      reportFilename: 'bundle-report.html',
      openAnalyzer: false,
      generateStatsFile: true,
      statsFilename: 'bundle-stats.json',
      statsOptions: {
        source: false,
        reasons: false,
        modules: true,
        chunks: true,
        chunkModules: true
      }
    })
  ]
};

// Vite Rollup Visualizer
import { visualizer } from 'rollup-plugin-visualizer';

export default {
  plugins: [
    visualizer({
      filename: './dist/stats.html',
      open: false,
      gzipSize: true,
      brotliSize: true,
      template: 'treemap' // 'sunburst', 'treemap', 'network'
    })
  ]
};

// NPM Script
// "scripts": {
//   "analyze": "vite build && vite-bundle-visualizer"
// }

Lazy Load Monitor (110 lines)

/**
 * Lazy Load Performance Monitor
 * Tracks chunk load times, cache hits, errors
 */

interface ChunkLoadMetric {
  chunkName: string;
  loadTime: number;
  cacheHit: boolean;
  size: number;
  timestamp: number;
}

class LazyLoadMonitor {
  private metrics: ChunkLoadMetric[] = [];
  private chunkCache: Set<string> = new Set();

  constructor() {
    this.initPerformanceObserver();
  }

  private initPerformanceObserver(): void {
    if (!('PerformanceObserver' in window)) return;

    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.entryType === 'resource' && entry.name.includes('chunk')) {
          this.trackChunkLoad(entry as PerformanceResourceTiming);
        }
      }
    });

    observer.observe({ entryTypes: ['resource'] });
  }

  private trackChunkLoad(entry: PerformanceResourceTiming): void {
    const chunkName = this.extractChunkName(entry.name);
    const cacheHit = entry.transferSize === 0;

    const metric: ChunkLoadMetric = {
      chunkName,
      loadTime: entry.responseEnd - entry.requestStart,
      cacheHit,
      size: entry.transferSize || entry.encodedBodySize,
      timestamp: entry.startTime
    };

    this.metrics.push(metric);

    if (!cacheHit) {
      this.chunkCache.add(chunkName);
    }

    // Log to analytics
    if (window.gtag) {
      window.gtag('event', 'chunk_loaded', {
        chunk_name: chunkName,
        load_time: metric.loadTime,
        cache_hit: cacheHit,
        size: metric.size
      });
    }

    console.log(`[Lazy Load] ${chunkName}: ${metric.loadTime.toFixed(2)}ms ${cacheHit ? '(cached)' : ''}`);
  }

  private extractChunkName(url: string): string {
    const match = url.match(/\/([^/]+)-[a-f0-9]+\.js$/);
    return match ? match[1] : 'unknown';
  }

  public getMetrics(): ChunkLoadMetric[] {
    return this.metrics;
  }

  public getAverageLoadTime(): number {
    if (this.metrics.length === 0) return 0;
    const sum = this.metrics.reduce((acc, m) => acc + m.loadTime, 0);
    return sum / this.metrics.length;
  }

  public getCacheHitRate(): number {
    if (this.metrics.length === 0) return 0;
    const hits = this.metrics.filter(m => m.cacheHit).length;
    return (hits / this.metrics.length) * 100;
  }

  public getTotalTransferSize(): number {
    return this.metrics.reduce((acc, m) => acc + m.size, 0);
  }

  public generateReport(): void {
    console.group('[Lazy Load Performance Report]');
    console.log(`Total Chunks Loaded: ${this.metrics.length}`);
    console.log(`Average Load Time: ${this.getAverageLoadTime().toFixed(2)}ms`);
    console.log(`Cache Hit Rate: ${this.getCacheHitRate().toFixed(1)}%`);
    console.log(`Total Transfer Size: ${(this.getTotalTransferSize() / 1024).toFixed(2)} KB`);
    console.table(this.metrics);
    console.groupEnd();
  }
}

// Usage
const monitor = new LazyLoadMonitor();

// Generate report after page load
window.addEventListener('load', () => {
  setTimeout(() => monitor.generateReport(), 5000);
});

export default LazyLoadMonitor;

Learn more about Core Web Vitals monitoring and performance budgets.

Production Best Practices {#production-best-practices}

1. Prefetch Strategy (110 lines)

/**
 * Intelligent Route Prefetching
 * Prefetches likely next routes during idle time
 */

interface PrefetchConfig {
  routes: { path: string; importFunc: () => Promise<any> }[];
  idleTimeout?: number;
  connection?: 'slow-2g' | '2g' | '3g' | '4g' | 'all';
}

class RoutePrefetcher {
  private config: PrefetchConfig;
  private prefetched: Set<string> = new Set();

  constructor(config: PrefetchConfig) {
    this.config = {
      idleTimeout: 2000,
      connection: '3g',
      ...config
    };

    this.initIdlePrefetch();
  }

  private initIdlePrefetch(): void {
    if ('requestIdleCallback' in window) {
      requestIdleCallback(() => this.prefetchRoutes(), {
        timeout: this.config.idleTimeout
      });
    } else {
      // Fallback for Safari
      setTimeout(() => this.prefetchRoutes(), this.config.idleTimeout);
    }
  }

  private shouldPrefetch(): boolean {
    // Don't prefetch on slow connections
    if ('connection' in navigator) {
      const conn = (navigator as any).connection;
      const effectiveType = conn?.effectiveType;

      if (this.config.connection !== 'all' && effectiveType) {
        const allowedTypes = ['3g', '4g'];
        if (!allowedTypes.includes(effectiveType)) {
          console.log('[Prefetch] Skipped (slow connection)');
          return false;
        }
      }
    }

    // Don't prefetch if data saver is enabled
    if ('connection' in navigator && (navigator as any).connection?.saveData) {
      console.log('[Prefetch] Skipped (data saver enabled)');
      return false;
    }

    return true;
  }

  private async prefetchRoutes(): Promise<void> {
    if (!this.shouldPrefetch()) return;

    for (const route of this.config.routes) {
      if (this.prefetched.has(route.path)) continue;

      try {
        console.log(`[Prefetch] Loading ${route.path}...`);
        await route.importFunc();
        this.prefetched.add(route.path);
        console.log(`[Prefetch] Loaded ${route.path}`);
      } catch (error) {
        console.error(`[Prefetch] Failed to load ${route.path}:`, error);
      }
    }
  }

  public prefetch(path: string): void {
    const route = this.config.routes.find(r => r.path === path);
    if (route && !this.prefetched.has(path)) {
      route.importFunc().then(() => {
        this.prefetched.add(path);
        console.log(`[Prefetch] Manually loaded ${path}`);
      });
    }
  }
}

// Usage
const prefetcher = new RoutePrefetcher({
  routes: [
    { path: '/dashboard', importFunc: () => import('./pages/Dashboard') },
    { path: '/dashboard/apps', importFunc: () => import('./pages/Apps') }
  ],
  idleTimeout: 3000,
  connection: '3g' // Only prefetch on 3G+ connections
});

export default RoutePrefetcher;

2. Error Handling Checklist

  • ✅ Wrap lazy imports in error boundaries
  • ✅ Provide retry mechanisms for chunk load failures
  • ✅ Show user-friendly error messages
  • ✅ Log errors to analytics (Google Analytics, Sentry)
  • ✅ Implement fallback routes (404 pages)

3. Testing Lazy Loading

# Test bundle size impact
npm run build
ls -lh dist/assets/*.js

# Test chunk load times (Chrome DevTools)
# 1. Open DevTools > Network > Disable cache
# 2. Reload page
# 3. Navigate to lazy-loaded route
# 4. Check "Time" column for chunk load duration

# Test slow connections (Chrome DevTools)
# 1. Open DevTools > Network > Throttling
# 2. Select "Slow 3G"
# 3. Test lazy loading behavior

# Lighthouse CI
npm install -g @lhci/cli
lhci autorun --collect.url=https://makeaihq.com

4. Lazy Loading Checklist

  • ✅ Route-based code splitting for all non-critical pages
  • ✅ Component lazy loading for heavy widgets (charts, editors)
  • ✅ Image lazy loading with Intersection Observer
  • ✅ Font optimization (font-display: swap, preloading)
  • ✅ Bundle analysis (Webpack Bundle Analyzer / Rollup Visualizer)
  • ✅ Prefetching for likely next routes
  • ✅ Error boundaries for chunk load failures
  • ✅ Performance monitoring (PerformanceObserver)
  • ✅ Cache headers for chunks (immutable, max-age=31536000)
  • ✅ 100/100 PageSpeed score validation

Conclusion: Ship Faster, Load Faster

Advanced lazy loading is the difference between a 6-second load and a 1.5-second load. By implementing route-based code splitting, component lazy loading, image lazy loading, and font optimization, you can reduce your ChatGPT app's initial bundle by 70%+ and achieve 100/100 PageSpeed scores.

Key Takeaways:

  • Route splitting reduces initial bundle by 60-70%
  • Component lazy loading defers non-critical UI (charts, modals)
  • Intersection Observer provides fine-grained image loading control
  • Font optimization (font-display: swap) eliminates FOIT
  • Bundle analysis identifies optimization opportunities
  • Prefetching improves perceived performance

Ready to optimize your ChatGPT app's performance? Start building with MakeAIHQ's AI-powered app builder and deploy to the ChatGPT App Store in 48 hours—no coding required.


Related Resources:

  • ChatGPT App Performance Optimization Guide
  • Core Web Vitals Optimization for ChatGPT Apps
  • Vite Build Optimization Strategies
  • Image Optimization: WebP & AVIF Guide
  • Bundle Size Analysis with Webpack
  • Performance Budgets Enforcement
  • Font Subsetting & Optimization

External Resources: