JavaScript and TypeScript course Β· Module 9: Design Patterns and Architecture
Lazy loading - images, routes, components
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.
Modal and drawer lazy loading
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:
- Reduces initial bundle size - faster application startup
- Improves Web Vitals - faster FCP and LCP, as long as you do not delay content that is visible right away
- Saves resources - memory, data transfer, battery
- Scales with the application - new features do not grow the startup bundle
- 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. What is the main benefit of lazy loading components?
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.