JavaScript and TypeScript course Β· Module 9: Design Patterns and Architecture

Lazy loading - images, routes, components

16 min read
In this lesson8

Imagine a visitor who has to collect the maps of every sector at the park gate before taking a single step, which is exactly what an application that loads everything at startup does. Lazy loading is an optimization strategy that delays loading resources until they are actually needed: when the user scrolls the page, navigates to a new route or performs an action that requires a specific component.

Benefits of lazy loading

You get a faster initial load: a smaller initial bundle, faster rendering of the first content (FCP) and a better perceived performance. You save RAM, data transfer and battery on mobile devices. The user gets smooth scrolling, progressive content loading and an experience adapted to the capabilities of the device and the connection.

Lazy loading images

Native lazy loading in HTML

The simplest approach needs no JavaScript. The loading attribute tells the browser when to fetch an image:

1<!-- Native lazy loading for images below the fold (supported by modern browsers) -->
2<img src="gallery-photo.jpg" alt="Gallery photo" loading="lazy" />
3
4<!-- Eager loading for above-the-fold images -->
5<img src="logo.jpg" alt="Logo" loading="eager" />
6
7<!-- No attribute: eager by default ("auto" is not part of the HTML standard) -->
8<img src="banner.jpg" alt="Banner" />

The standard knows only the values lazy and the default eager. Do not mark images that are visible right away as lazy, because you will delay the largest element of the page, and by setting width and height you avoid layout shifts.

Intersection Observer API

When you need more control, use Intersection Observer. This API reports when an element enters the visible area (viewport) without listening to every scroll:

1// Lazy loading implementation with Intersection Observer
2class LazyImageLoader {
3  constructor() {
4    this.imageObserver = new IntersectionObserver(
5      this.handleIntersection.bind(this),
6      {
7        root: null, // viewport
8        rootMargin: '50px', // load 50px before entering viewport
9        threshold: 0.1 // trigger when 10% of image is visible
10      }
11    );
12
13    this.initLazyImages();
14  }
15
16  initLazyImages() {
17    const lazyImages = document.querySelectorAll('img[data-src]');
18    lazyImages.forEach(img => {
19      this.imageObserver.observe(img);
20    });
21  }
22
23  handleIntersection(entries) {
24    entries.forEach(entry => {
25      if (entry.isIntersecting) {
26        const img = entry.target;
27        this.loadImage(img);
28        this.imageObserver.unobserve(img);
29      }
30    });
31  }
32
33  loadImage(img) {
34    // Load image from the data-src attribute
35    img.src = img.dataset.src;
36    img.classList.add('loaded');
37
38    // Remove data-src after loading
39    delete img.dataset.src;
40
41    // Error handling for loading
42    img.onerror = () => {
43      img.src = '/images/error-placeholder.png';
44      img.classList.add('error');
45    };
46  }
47}
48
49// Initialization
50document.addEventListener('DOMContentLoaded', () => {
51  new LazyImageLoader();
52});

The address waits in data-src until the image enters the viewport, and unobserve makes sure each image loads only once.

React components for lazy loading images

In React we wrap this logic in the useIntersectionObserver hook and the LazyImage component:

1// Hook for lazy loading images
2import { useState, useRef, useEffect } from 'react';
3
4function useIntersectionObserver(options = {}) {
5  const [isIntersecting, setIsIntersecting] = useState(false);
6  const [hasIntersected, setHasIntersected] = useState(false);
7  const elementRef = useRef(null);
8
9  useEffect(() => {
10    const element = elementRef.current;
11    if (!element) return;
12
13    const observer = new IntersectionObserver(
14      ([entry]) => {
15        const isElementIntersecting = entry.isIntersecting;
16        setIsIntersecting(isElementIntersecting);
17
18        if (isElementIntersecting && !hasIntersected) {
19          setHasIntersected(true);
20        }
21      },
22      { threshold: 0.1, rootMargin: '50px', ...options }
23    );
24
25    observer.observe(element);
26
27    return () => observer.disconnect();
28  }, [hasIntersected, options]);
29
30  return { elementRef, isIntersecting, hasIntersected };
31}
32
33// LazyImage component
34function LazyImage({ src, alt, placeholder, className = '' }) {
35  const [imageLoaded, setImageLoaded] = useState(false);
36  const [imageError, setImageError] = useState(false);
37  const { elementRef, hasIntersected } = useIntersectionObserver();
38
39  const handleImageLoad = () => {
40    setImageLoaded(true);
41  };
42
43  const handleImageError = () => {
44    setImageError(true);
45  };
46
47  return (
48    <div ref={elementRef} className={`lazy-image-container ${className}`}>
49      {/* Placeholder shown during loading */}
50      {!imageLoaded && (
51        <img
52          src={placeholder}
53          alt={`${alt} placeholder`}
54          className="placeholder-image"
55        />
56      )}
57
58      {/* Actual image loaded lazily */}
59      {hasIntersected && (
60        <img
61          src={imageError ? '/images/error.png' : src}
62          alt={alt}
63          onLoad={handleImageLoad}
64          onError={handleImageError}
65          className={`lazy-image ${imageLoaded ? 'loaded' : 'loading'}`}
66        />
67      )}
68
69      {/* Loading spinner */}
70      {hasIntersected && !imageLoaded && !imageError && (
71        <div className="image-spinner">Loading...</div>
72      )}
73    </div>
74  );
75}
76
77// Component usage
78function Gallery({ images }) {
79  return (
80    <div className="gallery">
81      {images.map((image, index) => (
82        <LazyImage
83          key={index}
84          src={image.url}
85          alt={image.alt}
86          placeholder={image.placeholder}
87          className="gallery-item"
88        />
89      ))}
90    </div>
91  );
92}

Note: the default options = {} is a new object on every render, so the effect recreates the observer on every render. In production, pass a stable object.

Progressive Image Loading

A well-known effect: a blurred preview smoothly turns into a sharp image:

1// Component with progressive loading (blur -> sharp)
2function ProgressiveImage({ src, placeholder, alt }) {
3  const [imageLoaded, setImageLoaded] = useState(false);
4  const { elementRef, hasIntersected } = useIntersectionObserver();
5
6  return (
7    <div ref={elementRef} className="progressive-image">
8      {/* Blurred placeholder */}
9      <img
10        src={placeholder}
11        alt={alt}
12        className={`progressive-placeholder`}
13        style={{
14          filter: 'blur(10px)',
15          transition: 'opacity 0.3s ease'
16        }}
17      />
18
19      {/* Sharp image */}
20      {hasIntersected && (
21        <img
22          src={src}
23          alt={alt}
24          onLoad={() => setImageLoaded(true)}
25          className={`main-image ${imageLoaded ? 'visible' : 'hidden'}`}
26          style={{
27            opacity: imageLoaded ? 1 : 0,
28            transition: 'opacity 0.3s ease'
29          }}
30        />
31      )}
32    </div>
33  );
34}

The lightweight placeholder is visible right away, and the full image fades in only after the onLoad event.

Lazy loading routes (Route-based)

React Router with Suspense

You gain the most by splitting code by routes. You define the page component, wrap it with the lazy function and a dynamic import(), and add a Suspense boundary with a fallback component:

1import { Suspense, lazy } from 'react';
2import { Routes, Route } from 'react-router';
3import ErrorBoundary from './components/ErrorBoundary';
4import LoadingSpinner from './components/LoadingSpinner';
5
6// Lazy loading route components
7const HomePage = lazy(() => import('./pages/HomePage'));
8const ProductsPage = lazy(() => import('./pages/ProductsPage'));
9const ProductDetailPage = lazy(() => import('./pages/ProductDetailPage'));
10const UserProfilePage = lazy(() => import('./pages/UserProfilePage'));
11const AdminPanel = lazy(() => import('./pages/AdminPanel'));
12
13// Components with custom loading and error handling
14const LazyRoute = ({ children }) => (
15  <ErrorBoundary>
16    <Suspense fallback={<LoadingSpinner />}>
17      {children}
18    </Suspense>
19  </ErrorBoundary>
20);
21
22function App() {
23  return (
24    <Routes>
25      <Route path="/" element={<LazyRoute><HomePage /></LazyRoute>} />
26      <Route path="/products" element={<LazyRoute><ProductsPage /></LazyRoute>} />
27      <Route path="/products/:id" element={<LazyRoute><ProductDetailPage /></LazyRoute>} />
28      <Route path="/profile" element={<LazyRoute><UserProfilePage /></LazyRoute>} />
29      <Route path="/admin/*" element={<LazyRoute><AdminPanel /></LazyRoute>} />
30    </Routes>
31  );
32}

A dynamic import() returns a Promise that resolves to the module, and the bundler moves each page into a separate file. Without React it is simply const module = await import('./module.js'). lazy requires a default export, and ErrorBoundary catches a failed download.

Advanced route loading strategies

A route can be fetched before the user clicks: on hovering over a link, based on the predicted path, or while the browser is idle:

1// Preloading routes based on user behavior
2class RoutePreloader {
3  constructor() {
4    this.preloadedRoutes = new Set();
5    this.setupPreloading();
6  }
7
8  setupPreloading() {
9    // Preload on hover
10    this.setupHoverPreloading();
11
12    // Preload based on probability
13    this.setupPredictivePreloading();
14
15    // Preload in idle time
16    this.setupIdlePreloading();
17  }
18
19  setupHoverPreloading() {
20    document.addEventListener('mouseover', (e) => {
21      const link = e.target.closest('a[href]');
22      if (link && this.shouldPreload(link.href)) {
23        this.preloadRoute(new URL(link.href).pathname);
24      }
25    });
26  }
27
28  setupPredictivePreloading() {
29    // If user is on /products, preload /products/:id
30    const currentPath = window.location.pathname;
31
32    if (currentPath === '/products') {
33      // Delay so as not to affect current page load
34      setTimeout(() => {
35        this.preloadRoute('/products/:id');
36      }, 2000);
37    }
38  }
39
40  setupIdlePreloading() {
41    if ('requestIdleCallback' in window) {
42      requestIdleCallback(() => {
43        this.preloadCriticalRoutes();
44      });
45    }
46  }
47
48  preloadRoute(path) {
49    if (this.preloadedRoutes.has(path)) return;
50
51    const routeMap = {
52      '/products': () => import('./pages/ProductsPage'),
53      '/products/:id': () => import('./pages/ProductDetailPage'),
54      '/profile': () => import('./pages/UserProfilePage'),
55      '/admin': () => import('./pages/AdminPanel')
56    };
57
58    const preloadFunction = routeMap[path];
59    if (preloadFunction) {
60      preloadFunction().then(() => {
61        this.preloadedRoutes.add(path);
62        console.log(`Route ${path} preloaded`);
63      });
64    }
65  }
66
67  shouldPreload(href) {
68    // Preload only internal links
69    return href.startsWith(window.location.origin);
70  }
71
72  preloadCriticalRoutes() {
73    const critical = ['/products', '/profile'];
74    critical.forEach(route => this.preloadRoute(route));
75  }
76}
77
78// Initialization
79new RoutePreloader();

requestIdleCallback waits until the browser has nothing more urgent to do (the code checks whether the function exists).

Lazy loading components

Component-level lazy loading

Heavy components, such as charts or a code editor, are loaded only after the tab that needs them is opened:

1// Lazy loading expensive components
2const ExpensiveChart = lazy(() => import('./components/ExpensiveChart'));
3const DataVisualization = lazy(() => import('./components/DataVisualization'));
4const VideoPlayer = lazy(() => import('./components/VideoPlayer'));
5const CodeEditor = lazy(() => import('./components/CodeEditor'));
6
7function Dashboard({ activeTab, data }) {
8  return (
9    <div className="dashboard">
10      <nav className="dashboard-nav">
11        {/* Navigation always visible */}
12      </nav>
13
14      <main className="dashboard-content">
15        {activeTab === 'charts' && (
16          <Suspense fallback={<div>Loading charts...</div>}>
17            <ExpensiveChart data={data} />
18          </Suspense>
19        )}
20
21        {activeTab === 'visualization' && (
22          <Suspense fallback={<div>Loading visualization...</div>}>
23            <DataVisualization data={data} />
24          </Suspense>
25        )}
26
27        {activeTab === 'video' && (
28          <Suspense fallback={<div>Loading player...</div>}>
29            <VideoPlayer />
30          </Suspense>
31        )}
32
33        {activeTab === 'editor' && (
34          <Suspense fallback={<div>Loading editor...</div>}>
35            <CodeEditor />
36          </Suspense>
37        )}
38      </main>
39    </div>
40  );
41}

The navigation appears immediately, and each tab has its own fallback.

Only some users ever open the modals, so the useLazyModal hook imports them only after a click:

1// Hook for lazy modals
2function useLazyModal() {
3  const [isOpen, setIsOpen] = useState(false);
4  const [Component, setComponent] = useState(null);
5
6  const openModal = async (modalType) => {
7    const modalMap = {
8      'user-settings': () => import('./modals/UserSettingsModal'),
9      'payment': () => import('./modals/PaymentModal'),
10      'image-editor': () => import('./modals/ImageEditorModal'),
11    };
12
13    const importFunction = modalMap[modalType];
14    if (importFunction) {
15      const { default: ModalComponent } = await importFunction();
16      setComponent(() => ModalComponent);
17      setIsOpen(true);
18    }
19  };
20
21  const closeModal = () => {
22    setIsOpen(false);
23    // Optionally: unload component after closing
24    setTimeout(() => setComponent(null), 300);
25  };
26
27  return { Component, isOpen, openModal, closeModal };
28}
29
30// Using lazy modals
31function App() {
32  const { Component: ModalComponent, isOpen, openModal, closeModal } = useLazyModal();
33
34  return (
35    <div>
36      <button onClick={() => openModal('user-settings')}>
37        User Settings
38      </button>
39      <button onClick={() => openModal('payment')}>
40        Payment
41      </button>
42      <button onClick={() => openModal('image-editor')}>
43        Image Editor
44      </button>
45
46      {isOpen && ModalComponent && (
47        <Suspense fallback={<div>Loading...</div>}>
48          <ModalComponent onClose={closeModal} />
49        </Suspense>
50      )}
51    </div>
52  );
53}

Writing setComponent(() => ModalComponent) is necessary, because useState would treat a function passed directly as a state updater.

Feature-based lazy loading

Whole features, such as the admin panel or the cart, are loaded with a skeleton instead of a spinner:

1// Lazy loading entire features
2const AdminFeature = lazy(() => import('./features/Admin'));
3const AnalyticsFeature = lazy(() => import('./features/Analytics'));
4const ShoppingCartFeature = lazy(() => import('./features/ShoppingCart'));
5
6function FeatureLoader({ feature, ...props }) {
7  const featureMap = {
8    admin: AdminFeature,
9    analytics: AnalyticsFeature,
10    cart: ShoppingCartFeature
11  };
12
13  const FeatureComponent = featureMap[feature];
14
15  if (!FeatureComponent) {
16    return <div>Unknown feature: {feature}</div>;
17  }
18
19  return (
20    <ErrorBoundary>
21      <Suspense fallback={<FeatureLoadingSkeleton feature={feature} />}>
22        <FeatureComponent {...props} />
23      </Suspense>
24    </ErrorBoundary>
25  );
26}
27
28// Skeleton for different features
29function FeatureLoadingSkeleton({ feature }) {
30  const skeletons = {
31    admin: <AdminSkeleton />,
32    analytics: <ChartsSkeleton />,
33    cart: <CartSkeleton />
34  };
35
36  return skeletons[feature] || <DefaultSkeleton />;
37}

A skeleton shows the shape of the upcoming content, so the page jumps around less.

Advanced lazy loading techniques

Virtual scrolling with lazy loading

Virtual scrolling renders only the visible rows of a long list, here with the react-window library:

1// Virtual scrolling for large lists
2import { List } from 'react-window';
3
4function VirtualizedList({ items, itemHeight = 50 }) {
5  const [loadedItems, setLoadedItems] = useState(new Set());
6
7  const loadItem = useCallback(async (index) => {
8    if (loadedItems.has(index)) return;
9
10    // Simulate data loading
11    await new Promise(resolve => setTimeout(resolve, 100));
12    setLoadedItems(prev => new Set([...prev, index]));
13  }, [loadedItems]);
14
15  const Row = ({ index, style }) => {
16    const item = items[index];
17    const isLoaded = loadedItems.has(index);
18
19    useEffect(() => {
20      if (!isLoaded) {
21        loadItem(index);
22      }
23    }, [index, isLoaded, loadItem]);
24
25    return (
26      <div style={style} className="list-item">
27        {isLoaded ? (
28          <ItemContent item={item} />
29        ) : (
30          <ItemSkeleton />
31        )}
32      </div>
33    );
34  };
35
36  return (
37    <List
38      style={{ height: 400 }}
39      rowComponent={Row}
40      rowCount={items.length}
41      rowHeight={itemHeight}
42      rowProps={{}}
43      overscanCount={5} // Preload 5 items beyond viewport
44    />
45  );
46}

overscanCount renders a few extra rows outside the viewport, so fast scrolling does not reveal empty space.

Lazy loading with cache

ComponentCache keeps components in a map, and preload fetches them in advance:

1// Cache for lazy-loaded components
2class ComponentCache {
3  constructor() {
4    this.cache = new Map();
5  }
6
7  async get(key, loader) {
8    if (this.cache.has(key)) {
9      return this.cache.get(key);
10    }
11
12    const component = await loader();
13    this.cache.set(key, component);
14    return component;
15  }
16
17  preload(key, loader) {
18    if (!this.cache.has(key)) {
19      this.get(key, loader);
20    }
21  }
22
23  clear(key) {
24    if (key) {
25      this.cache.delete(key);
26    } else {
27      this.cache.clear();
28    }
29  }
30}
31
32const componentCache = new ComponentCache();
33
34// Hook utilizing cache
35function useLazyComponent(componentKey, loader) {
36  const [Component, setComponent] = useState(null);
37  const [loading, setLoading] = useState(false);
38  const [error, setError] = useState(null);
39
40  const loadComponent = useCallback(async () => {
41    if (Component) return;
42
43    setLoading(true);
44    setError(null);
45
46    try {
47      const loadedComponent = await componentCache.get(componentKey, loader);
48      setComponent(() => loadedComponent.default);
49    } catch (err) {
50      setError(err);
51    } finally {
52      setLoading(false);
53    }
54  }, [componentKey, loader, Component]);
55
56  // Preload function
57  const preload = useCallback(() => {
58    componentCache.preload(componentKey, loader);
59  }, [componentKey, loader]);
60
61  return { Component, loading, error, loadComponent, preload };
62}

The browser will not execute a module twice anyway, but your own map gives you control: clearing, statistics or an expiry time (TTL).

Connection-aware lazy loading

On a slow connection we load less, and in lower quality. The Network Information API reports the effective connection type (effectiveType):

1// Adaptive loading based on connection
2function useConnectionAwareLazyLoading() {
3  const [connectionType, setConnectionType] = useState('4g');
4
5  useEffect(() => {
6    const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
7
8    if (connection) {
9      setConnectionType(connection.effectiveType);
10
11      const updateConnection = () => {
12        setConnectionType(connection.effectiveType);
13      };
14
15      connection.addEventListener('change', updateConnection);
16      return () => connection.removeEventListener('change', updateConnection);
17    }
18  }, []);
19
20  const shouldLazyLoad = useCallback((priority = 'normal') => {
21    const strategies = {
22      '4g': { immediate: true, normal: true, low: true },
23      '3g': { immediate: true, normal: true, low: false },
24      '2g': { immediate: true, normal: false, low: false },
25      'slow-2g': { immediate: false, normal: false, low: false }
26    };
27
28    return strategies[connectionType]?.[priority] ?? false;
29  }, [connectionType]);
30
31  const getLoadingStrategy = useCallback(() => {
32    const strategies = {
33      '4g': 'aggressive', // Load everything
34      '3g': 'moderate',   // Load only when needed
35      '2g': 'conservative', // Load minimally
36      'slow-2g': 'minimal' // Only essentials
37    };
38
39    return strategies[connectionType] || 'moderate';
40  }, [connectionType]);
41
42  return { connectionType, shouldLazyLoad, getLoadingStrategy };
43}
44
45// Component adapting to connection
46function AdaptiveImageGrid({ images }) {
47  const { shouldLazyLoad, getLoadingStrategy } = useConnectionAwareLazyLoading();
48  const strategy = getLoadingStrategy();
49
50  const imageSettings = {
51    aggressive: { quality: 'high', eager: 10 },
52    moderate: { quality: 'medium', eager: 4 },
53    conservative: { quality: 'low', eager: 2 },
54    minimal: { quality: 'thumbnail', eager: 1 }
55  };
56
57  const settings = imageSettings[strategy];
58
59  return (
60    <div className="image-grid">
61      {images.map((image, index) => (
62        <AdaptiveImage
63          key={image.id}
64          src={image.url}
65          quality={settings.quality}
66          placeholder={image.placeholder}
67          loading={index < settings.eager ? 'eager' : 'lazy'}
68          shouldOptimize={strategy !== 'aggressive'}
69        />
70      ))}
71    </div>
72  );
73}

This API works only in Chromium-based browsers, which is why the hook starts with the value '4g' and checks whether navigator.connection exists.

Performance monitoring for lazy loading

You cannot judge an optimization by eye. LazyLoadingMonitor measures loading time, errors and cache hits:

1// Lazy loading performance monitoring
2class LazyLoadingMonitor {
3  constructor() {
4    this.metrics = {
5      componentsLoaded: 0,
6      totalLoadTime: 0,
7      failedLoads: 0,
8      cacheHits: 0
9    };
10
11    this.startTime = performance.now();
12  }
13
14  trackComponentLoad(componentName, loadTime, fromCache = false) {
15    this.metrics.componentsLoaded++;
16    this.metrics.totalLoadTime += loadTime;
17
18    if (fromCache) {
19      this.metrics.cacheHits++;
20    }
21
22    // Web Vitals tracking
23    if (loadTime > 2500) { // Slow loading threshold
24      console.warn(`Slow lazy load detected: ${componentName} took ${loadTime}ms`);
25    }
26
27    // Send to analytics
28    this.sendMetrics('component_lazy_load', {
29      component: componentName,
30      loadTime,
31      fromCache,
32      timestamp: Date.now()
33    });
34  }
35
36  trackFailedLoad(componentName, error) {
37    this.metrics.failedLoads++;
38
39    console.error(`Failed to lazy load ${componentName}:`, error);
40
41    this.sendMetrics('component_lazy_load_error', {
42      component: componentName,
43      error: error.message,
44      timestamp: Date.now()
45    });
46  }
47
48  getAverageLoadTime() {
49    return this.metrics.componentsLoaded > 0
50      ? this.metrics.totalLoadTime / this.metrics.componentsLoaded
51      : 0;
52  }
53
54  getCacheHitRate() {
55    return this.metrics.componentsLoaded > 0
56      ? (this.metrics.cacheHits / this.metrics.componentsLoaded) * 100
57      : 0;
58  }
59
60  sendMetrics(event, data) {
61    // Integration with Google Analytics, Mixpanel, etc.
62    if (typeof gtag !== 'undefined') {
63      gtag('event', event, data);
64    }
65  }
66
67  generateReport() {
68    const sessionTime = performance.now() - this.startTime;
69
70    return {
71      sessionDuration: sessionTime,
72      componentsLoaded: this.metrics.componentsLoaded,
73      averageLoadTime: this.getAverageLoadTime(),
74      cacheHitRate: this.getCacheHitRate(),
75      failureRate: (this.metrics.failedLoads / Math.max(1, this.metrics.componentsLoaded + this.metrics.failedLoads)) * 100,
76      totalFailures: this.metrics.failedLoads
77    };
78  }
79}
80
81// Singleton instance
82const lazyLoadMonitor = new LazyLoadingMonitor();
83
84// Hook with monitoring
85function useMonitoredLazyComponent(componentName, loader) {
86  const [Component, setComponent] = useState(null);
87  const [loading, setLoading] = useState(false);
88
89  const loadComponent = useCallback(async () => {
90    if (Component) return;
91
92    const startTime = performance.now();
93    setLoading(true);
94
95    try {
96      const loadedComponent = await loader();
97      const loadTime = performance.now() - startTime;
98
99      setComponent(() => loadedComponent.default);
100      lazyLoadMonitor.trackComponentLoad(componentName, loadTime);
101    } catch (error) {
102      lazyLoadMonitor.trackFailedLoad(componentName, error);
103      throw error;
104    } finally {
105      setLoading(false);
106    }
107  }, [componentName, loader, Component]);
108
109  return { Component, loading, loadComponent };
110}

The 2500 ms threshold is the limit of a good LCP score.

Best practices for lazy loading

1. Content prioritization

The most important rule: load what is visible right away immediately, and everything else lazily:

1// Above-the-fold content - eager loading
2// Below-the-fold content - lazy loading
3function ContentStrategy() {
4  return (
5    <>
6      {/* Critical path - eager */}
7      <Header />
8      <Hero />
9      <MainContent />
10
11      {/* Secondary content - lazy */}
12      <Suspense fallback={<SectionSkeleton />}>
13        <LazySection name="testimonials" />
14      </Suspense>
15
16      <Suspense fallback={<SectionSkeleton />}>
17        <LazySection name="newsletter" />
18      </Suspense>
19
20      {/* Low priority - very lazy */}
21      <Suspense fallback={<FooterSkeleton />}>
22        <LazyFooter />
23      </Suspense>
24    </>
25  );
26}

The header and the main content do not wait, while the testimonials and the footer arrive in the background.

2. Graceful fallbacks

Lazy loading can fail, for example when the connection drops, so always combine Suspense with an error boundary:

1// Always provide graceful fallbacks
2function LazyComponentWrapper({ children, fallback, errorFallback }) {
3  return (
4    <ErrorBoundary
5      fallback={errorFallback || <div>An error occurred during loading</div>}
6    >
7      <Suspense fallback={fallback || <LoadingSkeleton />}>
8        {children}
9      </Suspense>
10    </ErrorBoundary>
11  );
12}

Instead of a blank screen the user sees a skeleton or a clear message.

3. Strategic preloading

Finally, prediction: shortly after the page loads we fetch the components that will probably be needed:

1// Strategic preloading based on user behavior
2function useStrategicPreloading() {
3  useEffect(() => {
4    // Preload 2 seconds after the component mounts
5    const timeoutId = setTimeout(() => {
6      // Preload likely next components
7      import('./components/UserProfile');
8      import('./components/ShoppingCart');
9    }, 2000);
10
11    // Preload based on mouse movement toward a link
12    const handleMouseMove = (e) => {
13      // If cursor is approaching a specific element
14      // preload the associated component
15    };
16
17    document.addEventListener('mousemove', handleMouseMove);
18
19    return () => {
20      clearTimeout(timeoutId);
21      document.removeEventListener('mousemove', handleMouseMove);
22    };
23  }, []);
24}

The function returned from useEffect removes the timer and the listener when the component goes away.

Summary

Lazy loading in short:

  1. Reduces initial bundle size - faster application startup
  2. Improves Web Vitals - faster FCP and LCP, as long as you do not delay content that is visible right away
  3. Saves resources - memory, data transfer, battery
  4. Scales with the application - new features do not grow the startup bundle
  5. Adapts to conditions - loading tailored to the connection

Lazy loading should be invisible to the user while clearly speeding up the application. It is one of several techniques, next to caching, debouncing (postponing an expensive operation until the user stops typing) and virtual scrolling. Start with splitting by routes because it gives the biggest gain for the smallest cost. In the next lesson you will measure and slim down the bundle itself.

Remember: a visitor does not need maps of the whole island at the gate, only the next map when entering a new sector.

In the preview below, scroll through the dinosaur gallery: the cards load only when they come into view, and the counter shows how many kilobytes have been downloaded so far.

Code for this lesson: lazy-loading-demo.html
1<!DOCTYPE html>
2<html lang="en">
3<head>
4    <meta charset="UTF-8">
5    <meta name="viewport" content="width=device-width, initial-scale=1.0">
6    <title>Lazy Loading - Jurassic Park</title>
7    <style>
8        body {
9            font-family: 'Courier New', monospace;
10            background: linear-gradient(135deg, #1e3c28 0%, #2d5a3d 100%);
11            color: #e0e0e0;
12            margin: 0;
13            padding: 20px;
14        }
15
16        .header {
17            text-align: center;
18            padding: 20px;
19            background: rgba(0, 0, 0, 0.3);
20            border-radius: 10px;
21            margin-bottom: 30px;
22        }
23
24        h1 {
25            color: #4CAF50;
26            text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
27        }
28
29        .gallery {
30            display: grid;
31            grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
32            gap: 20px;
33            max-width: 1200px;
34            margin: 0 auto;
35        }
36
37        .dino-card {
38            background: rgba(255, 255, 255, 0.1);
39            border-radius: 10px;
40            padding: 15px;
41            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
42            transition: transform 0.3s ease;
43        }
44
45        .dino-card:hover {
46            transform: translateY(-5px);
47        }
48
49        .dino-image {
50            width: 100%;
51            height: 200px;
52            background: linear-gradient(45deg, #2d5a3d 0%, #3d6a4d 100%);
53            border-radius: 8px;
54            display: flex;
55            align-items: center;
56            justify-content: center;
57            font-size: 60px;
58            transition: opacity 0.5s ease;
59            position: relative;
60            overflow: hidden;
61        }
62
63        .dino-image.loading {
64            opacity: 0.5;
65        }
66
67        .dino-image.loaded {
68            opacity: 1;
69            background: linear-gradient(45deg, #4CAF50 0%, #45a049 100%);
70        }
71
72        .dino-image::after {
73            content: 'Loading...';
74            position: absolute;
75            bottom: 10px;
76            font-size: 12px;
77            color: #fff;
78            opacity: 0;
79            transition: opacity 0.3s;
80        }
81
82        .dino-image.loading::after {
83            opacity: 1;
84        }
85
86        .dino-info {
87            margin-top: 10px;
88        }
89
90        .dino-name {
91            font-size: 18px;
92            font-weight: bold;
93            color: #4CAF50;
94            margin-bottom: 5px;
95        }
96
97        .dino-status {
98            font-size: 14px;
99            color: #aaa;
100        }
101
102        .stats {
103            background: rgba(0, 0, 0, 0.3);
104            padding: 20px;
105            border-radius: 10px;
106            margin: 30px auto;
107            max-width: 600px;
108            text-align: center;
109        }
110
111        .stat-item {
112            display: inline-block;
113            margin: 0 15px;
114        }
115
116        .stat-value {
117            font-size: 24px;
118            color: #4CAF50;
119            font-weight: bold;
120        }
121    </style>
122</head>
123<body>
124    <div class="header">
125        <h1>Lazy Loading System - Jurassic Park</h1>
126        <p>Watch the dinosaurs load on demand!</p>
127    </div>
128
129    <div class="stats" id="stats">
130        <div class="stat-item">
131            <div class="stat-value" id="loaded-count">0</div>
132            <div>Loaded</div>
133        </div>
134        <div class="stat-item">
135            <div class="stat-value" id="total-count">0</div>
136            <div>Total</div>
137        </div>
138        <div class="stat-item">
139            <div class="stat-value" id="bandwidth-saved">0 KB</div>
140            <div>Downloaded</div>
141        </div>
142    </div>
143
144    <div class="gallery" id="gallery"></div>
145
146    <script>
147        console.log('Initializing the Lazy Loading system for Jurassic Park...');
148
149        // Dinosaur data
150        const dinosaurs = [
151            { name: 'Tyrannosaurus Rex', emoji: '', species: 'T-Rex', size: 150 },
152            { name: 'Velociraptor', emoji: '', species: 'Raptor', size: 80 },
153            { name: 'Triceratops', emoji: '', species: 'Herbivore', size: 120 },
154            { name: 'Brachiosaurus', emoji: '', species: 'Sauropod', size: 200 },
155            { name: 'Stegosaurus', emoji: '', species: 'Herbivore', size: 110 },
156            { name: 'Pteranodon', emoji: '', species: 'Flying', size: 90 },
157            { name: 'Ankylosaurus', emoji: '', species: 'Armored', size: 130 },
158            { name: 'Spinosaurus', emoji: '', species: 'Carnivore', size: 170 },
159            { name: 'Parasaurolophus', emoji: '', species: 'Hadrosaur', size: 100 },
160            { name: 'Dilophosaurus', emoji: '', species: 'Carnivore', size: 95 }
161        ];
162
163        // Statistics
164        let loadedCount = 0;
165        let totalBandwidthSaved = 0;
166
167        // Class for handling lazy loading
168        class DinosaurLazyLoader {
169            constructor() {
170                this.observer = new IntersectionObserver(
171                    this.handleIntersection.bind(this),
172                    {
173                        root: null,
174                        rootMargin: '100px',
175                        threshold: 0.1
176                    }
177                );
178
179                console.log('Intersection Observer created');
180            }
181
182            handleIntersection(entries) {
183                entries.forEach(entry => {
184                    if (entry.isIntersecting) {
185                        const card = entry.target;
186                        this.loadDinosaur(card);
187                        this.observer.unobserve(card);
188                    }
189                });
190            }
191
192            loadDinosaur(card) {
193                const imageDiv = card.querySelector('.dino-image');
194                const emoji = card.dataset.emoji;
195                const size = parseInt(card.dataset.size);
196
197                console.log(`Loading dinosaur: ${card.dataset.name}`);
198
199                // Simulate loading delay
200                setTimeout(() => {
201                    imageDiv.textContent = emoji;
202                    imageDiv.classList.remove('loading');
203                    imageDiv.classList.add('loaded');
204
205                    loadedCount++;
206                    totalBandwidthSaved += size;
207
208                    this.updateStats();
209
210                    console.log(`Dinosaur loaded: ${card.dataset.name}`);
211                }, Math.random() * 500 + 200);
212            }
213
214            updateStats() {
215                document.getElementById('loaded-count').textContent = loadedCount;
216                document.getElementById('bandwidth-saved').textContent = totalBandwidthSaved + ' KB';
217            }
218
219            observe(element) {
220                this.observer.observe(element);
221            }
222        }
223
224        // Initialization
225        const loader = new DinosaurLazyLoader();
226        const gallery = document.getElementById('gallery');
227
228        // Creating dinosaur cards
229        dinosaurs.forEach((dino, index) => {
230            const card = document.createElement('div');
231            card.className = 'dino-card';
232            card.dataset.name = dino.name;
233            card.dataset.emoji = dino.emoji;
234            card.dataset.size = dino.size;
235
236            card.innerHTML = `
237                <div class="dino-image loading"></div>
238                <div class="dino-info">
239                    <div class="dino-name">${dino.name}</div>
240                    <div class="dino-status">Species: ${dino.species} | ID: #${(index + 1).toString().padStart(3, '0')}</div>
241                </div>
242            `;
243
244            gallery.appendChild(card);
245            loader.observe(card);
246        });
247
248        // Updating statistics
249        document.getElementById('total-count').textContent = dinosaurs.length;
250
251        console.log(`Created ${dinosaurs.length} dinosaur cards`);
252        console.log('Scroll the page to watch lazy loading in action!');
253        console.log('Dinosaurs will load automatically when they enter the viewport');
254    </script>
255</body>
256</html>

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What is the main benefit of lazy loading components?

  2. 2. What does the dynamic import() expression return in JavaScript?

These are 2 of 3 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Vertical ordering

    Arrange the stages of lazy loading component configuration:

  • Horizontal ordering

    Arrange the elements of a dynamic import in the correct order:

  • Code editor

    The index.js file has a DinosaurAI class with needs (hunger, fatigue, danger on a 0-100 scale) and a decision tree. Fill in the blanks: ___BLANK1___ is the lower limit of a need, below which updateNeed does not lower it, ___BLANK2___ is the array method that goes through the need names and keeps the one with the highest value, and ___BLANK3___ is the comparison operator in decide(): we choose the action of a need only when its value is greater than 30. Danger above 70 always gives 'flee', and when no need exceeds 30, decide() returns 'idle'.

  • Click in order

    Arrange the elements of subscription in the Observer pattern:

  • Code editor

    The index.js file has a DataManager class with a Map cache (with a TTL) and storage in localStorage. Fill in the blanks: ___BLANK1___ is the JSON method that turns a value into text before it is stored in localStorage, ___BLANK2___ is the comparison operator that makes get() return the value from the cache only before the expires time, and ___BLANK3___ is the JSON method that turns the text from localStorage back into a value. After an entry expires, get() reads the value from localStorage, for a missing key it returns null, and invalidate removes the key from both places.

Useful articles