JavaScript and TypeScript course Β· Module 9: Design Patterns and Architecture
Code splitting and tree shaking
In this lesson11
In the world of modern web development, where applications are becoming increasingly complex, managing the size of JavaScript bundles is crucial for performance. Two fundamental optimization techniques - code splitting and tree shaking - allow us to drastically reduce the size of code delivered to users.
What is code splitting?
Code splitting is a technique of dividing code into smaller fragments (chunks) that can be loaded on demand (lazy loading) or in parallel. Instead of sending all the application code in one large bundle, we can divide it into logical parts and load only what we currently need.
Problems solved by code splitting
1// Problem: One huge bundle
2// main.bundle.js (2.5MB)
3// - Homepage code (200KB)
4// - Admin panel code (800KB)
5// - Online store code (1.5MB)
6// A user viewing only the homepage unnecessarily downloads 2.3MB of code!
7
8// Solution: Code splitting
9// main.bundle.js (200KB) - only homepage code
10// admin.chunk.js (800KB) - loaded only when needed
11// shop.chunk.js (1.5MB) - loaded only when neededTypes of code splitting
1. Route-based code splitting
The simplest and most commonly used type - splitting code by application routes:
1// React Router with lazy loading
2import { lazy, Suspense } from 'react';
3import { Routes, Route } from 'react-router';
4
5// Each component is a separate chunk
6const HomePage = lazy(() => import('./pages/HomePage'));
7const ProductPage = lazy(() => import('./pages/ProductPage'));
8const AdminPanel = lazy(() => import('./pages/AdminPanel'));
9
10function App() {
11 return (
12 <Suspense fallback={<div>Loading...</div>}>
13 <Routes>
14 <Route path="/" element={<HomePage />} />
15 <Route path="/product/:id" element={<ProductPage />} />
16 <Route path="/admin" element={<AdminPanel />} />
17 </Routes>
18 </Suspense>
19 );
20}2. Component-based code splitting
Splitting code at the component level:
1// Lazy loading individual components
2const ExpensiveChart = lazy(() => import('./components/ExpensiveChart'));
3const HeavyDataTable = lazy(() => import('./components/HeavyDataTable'));
4
5function Dashboard({ showChart, showTable }) {
6 return (
7 <div>
8 <h1>Main panel</h1>
9
10 {showChart && (
11 <Suspense fallback={<div>Loading chart...</div>}>
12 <ExpensiveChart />
13 </Suspense>
14 )}
15
16 {showTable && (
17 <Suspense fallback={<div>Loading table...</div>}>
18 <HeavyDataTable />
19 </Suspense>
20 )}
21 </div>
22 );
23}3. Dynamic imports
Importing code in response to user actions:
1// Loading a library only when needed
2async function showAdvancedEditor() {
3 // CodeMirror is loaded only when the user clicks the button
4 const { EditorView, basicSetup } = await import('codemirror');
5 const { javascript } = await import('@codemirror/lang-javascript');
6
7 const editor = new EditorView({
8 extensions: [basicSetup, javascript()],
9 parent: document.getElementById('editor-container')
10 });
11}
12
13// Event listener on button
14document.getElementById('show-editor').addEventListener('click', showAdvancedEditor);4. Vendor code splitting
Separating external libraries from application code:
1// webpack.config.js
2module.exports = {
3 optimization: {
4 splitChunks: {
5 chunks: 'all',
6 cacheGroups: {
7 // External libraries in a separate chunk
8 vendor: {
9 test: /[\\/]node_modules[\\/]/,
10 name: 'vendors',
11 chunks: 'all',
12 },
13 // Common application code
14 common: {
15 name: 'common',
16 minChunks: 2,
17 chunks: 'all',
18 enforce: true
19 }
20 }
21 }
22 }
23};What is tree shaking?
Tree shaking is the process of removing "dead code" - code that is not used in the application. The name comes from the metaphor of "shaking a tree", where only unnecessary leaves (unused code) fall off.
How does tree shaking work?
1// utils.js - we export many functions
2export function formatDate(date) {
3 return date.toLocaleDateString();
4}
5
6export function formatTime(date) {
7 return date.toLocaleTimeString();
8}
9
10export function formatCurrency(amount) {
11 return new Intl.NumberFormat('pl-PL', {
12 style: 'currency',
13 currency: 'PLN'
14 }).format(amount);
15}
16
17export function validateEmail(email) {
18 return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
19}
20
21// main.js - we import only what we need
22import { formatDate, formatCurrency } from './utils.js';
23
24// Tree shaking will remove formatTime and validateEmail from the final bundle
25console.log(formatDate(new Date()));
26console.log(formatCurrency(100));Requirements for tree shaking
- ES Modules (ESM) - tree shaking only works with ES6 modules:
1// Works with tree shaking (ES modules)
2import { specificFunction } from './module.js';
3
4// Does not work with tree shaking (CommonJS)
5const { specificFunction } = require('./module.js');- Static imports - imports must be static:
1// Static import - tree shaking works
2import { debounce } from 'lodash-es';
3
4// Dynamic import - tree shaking does not work
5const moduleName = 'lodash-es';
6import(moduleName).then(module => {
7 const { debounce } = module;
8});- Named exports - prefer named exports over default exports:
1// Named exports - better for tree shaking
2export const add = (a, b) => a + b;
3export const subtract = (a, b) => a - b;
4
5// Default export with object - worse for tree shaking
6export default {
7 add: (a, b) => a + b,
8 subtract: (a, b) => a - b
9};Practical optimization examples
CSS library optimization
1// Importing the entire Bootstrap library
2import 'bootstrap/dist/css/bootstrap.css';
3
4// Importing only needed modules (your own SCSS file)
5import './bootstrap-custom.scss';1// bootstrap-custom.scss (Bootstrap 5): Sass partials are imported from SCSS, not from JavaScript
2@import 'bootstrap/scss/functions';
3@import 'bootstrap/scss/variables';
4@import 'bootstrap/scss/variables-dark';
5@import 'bootstrap/scss/maps';
6@import 'bootstrap/scss/mixins';
7@import 'bootstrap/scss/utilities';
8
9// Keep only display (d-*), margin (m-*) and padding (p-*) classes
10$utilities: map-get-multiple($utilities, ('display', 'margin', 'padding'));
11
12@import 'bootstrap/scss/utilities/api';JavaScript library optimization
1// Importing the entire Lodash library (>70KB)
2import _ from 'lodash';
3const result = _.debounce(handleSearch, 300);
4
5// Importing only the needed function from lodash-es
6import { debounce } from 'lodash-es';
7const result = debounce(handleSearch, 300);
8
9// Or even better - single function
10import debounce from 'lodash-es/debounce';
11const result = debounce(handleSearch, 300);Tree shaking in React
1// Namespace import - with static access (MUI.Button) the bundler still drops unused components
2import * as MUI from '@mui/material';
3
4// Importing only needed components
5import { Button, TextField, Card } from '@mui/material';
6
7// Or individual imports
8import Button from '@mui/material/Button';
9import TextField from '@mui/material/TextField';
10import Card from '@mui/material/Card';Bundler configuration
Webpack
1// webpack.config.js
2module.exports = {
3 mode: 'production', // Enables tree shaking automatically
4 optimization: {
5 usedExports: true, // Marks used exports
6 sideEffects: true, // Honors the "sideEffects" field in package.json (default in production mode)
7 splitChunks: {
8 chunks: 'all',
9 cacheGroups: {
10 vendor: {
11 test: /[\\/]node_modules[\\/]/,
12 name: 'vendors',
13 chunks: 'all',
14 }
15 }
16 }
17 }
18};Vite
1// vite.config.js - Vite 8+ (Rolldown); in Vite 7 and older: build.rollupOptions.output.manualChunks
2import { defineConfig } from 'vite';
3
4export default defineConfig({
5 build: {
6 rolldownOptions: {
7 output: {
8 codeSplitting: {
9 groups: [
10 { name: 'vendor', test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/ },
11 { name: 'utils', test: /[\\/]node_modules[\\/](lodash-es|date-fns)[\\/]/ }
12 ]
13 }
14 }
15 }
16 }
17});Rollup
1// rollup.config.js
2export default {
3 input: 'src/main.js',
4 output: {
5 dir: 'dist',
6 format: 'es',
7 manualChunks: (id) => {
8 if (id.includes('node_modules')) {
9 return 'vendor';
10 }
11 }
12 },
13 external: ['react', 'react-dom']
14};Advanced techniques
Preloading and prefetching
1// Preload - high priority, needed immediately
2const AdminPanel = lazy(() =>
3 import(/* webpackPreload: true */ './AdminPanel')
4);
5
6// Prefetch - low priority, likely needed later
7const UserProfile = lazy(() =>
8 import(/* webpackPrefetch: true */ './UserProfile')
9);Bundle analysis
The webpack-bundle-analyzer plugin writes an HTML report with a map of the bundle on every build: each module is a rectangle whose area is proportional to its size, so you can see right away what takes up the most space:
1// Bundle analysis using webpack-bundle-analyzer
2const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
3
4module.exports = {
5 plugins: [
6 new BundleAnalyzerPlugin({
7 analyzerMode: 'static',
8 openAnalyzer: false,
9 generateStatsFile: true
10 })
11 ]
12};Custom loading strategies
You can also pick the version of a component based on the connection quality reported by the Network Information API (navigator.connection). It works only in Chromium-based browsers, so in the others the code picks the lighter version:
1// Loading strategy based on internet connection
2async function loadComponentBasedOnConnection() {
3 const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
4
5 if (connection && connection.effectiveType === '4g') {
6 // Fast connection - load full version
7 return import('./components/FullFeaturedComponent');
8 } else {
9 // Slow connection - load simplified version
10 return import('./components/LightweightComponent');
11 }
12}Side effects and package.json
The sideEffects field in a library's package.json tells the bundler whether its modules have side effects. The value false means that no file has any, so the bundler can skip a module you import nothing from entirely:
1{
2 "name": "my-library",
3 "version": "1.0.0",
4 "sideEffects": false,
5 "main": "dist/index.js",
6 "module": "dist/index.esm.js"
7}Files with side effects that the bundler must keep are listed in an array:
1{
2 "sideEffects": [
3 "src/polyfills.js",
4 "src/global-styles.css",
5 "**/*.css"
6 ]
7}Practical tips
1. Monitoring bundle size
Before you add a library to the project, check how much it weighs after minification and after gzip compression:
1# Using bundlephobia to check library size
2npm install -g bundle-phobia-cli
3bundle-phobia lodash
4bundle-phobia lodash-es2. Webpack Bundle Analyzer
1npm install --save-dev webpack-bundle-analyzer
2npx webpack --profile --json=stats.json
3npx webpack-bundle-analyzer stats.json3. Lighthouse CI for monitoring
1# .github/workflows/lighthouse.yml
2- name: Lighthouse CI
3 run: |
4 npm install -g @lhci/cli
5 lhci autorunFull optimization example
1// Before optimization
2import React from 'react';
3import * as MUI from '@mui/material';
4import _ from 'lodash';
5import moment from 'moment';
6import './styles.css';
7
8function App() {
9 const [users, setUsers] = React.useState([]);
10
11 const debouncedSearch = _.debounce((query) => {
12 // Search users
13 }, 300);
14
15 return (
16 <MUI.Container>
17 <MUI.TextField onChange={(e) => debouncedSearch(e.target.value)} />
18 {users.map(user => (
19 <MUI.Card key={user.id}>
20 <MUI.Typography>
21 {user.name} - {moment(user.createdAt).format('DD/MM/YYYY')}
22 </MUI.Typography>
23 </MUI.Card>
24 ))}
25 </MUI.Container>
26 );
27}1// After optimization
2import React, { useState, lazy, Suspense } from 'react';
3import { Container, TextField } from '@mui/material';
4import { debounce } from 'lodash-es';
5import { format } from 'date-fns';
6import { pl } from 'date-fns/locale';
7
8// Lazy loading for rarely used components
9const UserCard = lazy(() => import('./components/UserCard'));
10
11function App() {
12 const [users, setUsers] = useState([]);
13
14 const debouncedSearch = debounce((query) => {
15 // Search users
16 }, 300);
17
18 return (
19 <Container>
20 <TextField onChange={(e) => debouncedSearch(e.target.value)} />
21 <Suspense fallback={<div>Loading...</div>}>
22 {users.map(user => (
23 <UserCard
24 key={user.id}
25 name={user.name}
26 date={format(new Date(user.createdAt), 'dd/MM/yyyy', { locale: pl })}
27 />
28 ))}
29 </Suspense>
30 </Container>
31 );
32}Benefits and results
Bundle size savings
- Lodash: 70KB -> 2KB (single function)
- Moment.js: 230KB -> 11KB (date-fns)
- Material-UI: 1.2MB -> 200KB (only used components)
- Bootstrap: 150KB -> 30KB (only needed modules)
Performance impact
1// Performance metrics
2const performanceMetrics = {
3 beforeOptimization: {
4 bundleSize: '2.5MB',
5 firstContentfulPaint: '3.2s',
6 largestContentfulPaint: '4.8s',
7 timeToInteractive: '5.2s'
8 },
9 afterOptimization: {
10 bundleSize: '450KB',
11 firstContentfulPaint: '1.1s',
12 largestContentfulPaint: '1.8s',
13 timeToInteractive: '2.1s'
14 }
15};Summary
Code splitting and tree shaking are fundamental optimization techniques for modern web applications. They allow you to:
- Reduce initial bundle size - users download only the code needed for the first render
- Speed up loading time - smaller files = faster downloads
- Improve User Experience - the application runs faster and smoother
- Save data transfer - especially important on mobile devices
- Enable better caching - vendor libraries change rarely
The key to success is a systematic approach: regular bundle audits, library size monitoring, and conscious decisions about what dependencies we add to the project.
In the editor below you will calculate, on sample data, how many kilobytes a visitor saves at startup thanks to code splitting and tree shaking.
Code for this lesson: index.js
1// Code splitting and tree shaking in numbers: a simulation of the Jurassic Park bundle
2// Change the data below and watch how many kilobytes a visitor downloads at startup.
3
4// static = a regular import (the module goes into the initial bundle)
5// dynamic = import('./file.js') (the bundler moves it into a separate chunk downloaded on demand)
6const modules = [
7 { file: 'app.js', kb: 45, load: 'static' },
8 { file: 'park-map.js', kb: 120, load: 'static' },
9 { file: 'admin-panel.js', kb: 310, load: 'dynamic' },
10 { file: 'genetics-charts.js', kb: 180, load: 'dynamic' },
11];
12
13const totalKb = (list) => list.reduce((sum, m) => sum + m.kb, 0);
14const initial = modules.filter((m) => m.load === 'static');
15const chunks = modules.filter((m) => m.load === 'dynamic');
16
17console.log('Without code splitting at startup:', totalKb(modules), 'kB');
18console.log('With code splitting at startup:', totalKb(initial), 'kB');
19console.log('Chunks downloaded on demand:', chunks.map((m) => m.file + ' (' + m.kb + ' kB)').join(', '));
20
21// Tree shaking: only the exports you import go into the bundle (sizes in kB)
22const parkUtils = { formatDate: 2, debounce: 3, deepClone: 6, chartEngine: 95 };
23const imported = ['formatDate', 'debounce'];
24const libraryKb = Object.values(parkUtils).reduce((sum, kb) => sum + kb, 0);
25const shakenKb = imported.reduce((sum, name) => sum + parkUtils[name], 0);
26console.log('Whole library:', libraryKb, 'kB');
27console.log('After tree shaking:', shakenKb, 'kB');
28
29// Try it: change the load of park-map.js to 'dynamic' or add 'chartEngine' to imported.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 characterizes a pure function?
2. What is immutable state?
These are 2 of 4 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Vertical ordering
Arrange the steps for implementing immutable state:
- Code editor
The index.html file has a #park-app container and a script with the ParkInterface class, which builds the park dashboard from the parkData data (the look of the panels is in style.css). Fill in the blanks: ___BLANK1___ is the array method that turns every dinosaur into a string with an <li> element, ___BLANK2___ is the array method that joins these strings into one (without commas between them), and ___BLANK3___ is the element property through which render() inserts the finished HTML into the container. After the page runs, #park-app should contain three .panel panels with h2 headings, the first one with a list of all dinosaurs; render() should also work for another container and other data.
- Click in order
Arrange the elements of the Factory pattern:
- Code editor
The index.js file has a TimeSimulation class. Fill in the blanks: ___BLANK1___ is the number of hours in a day, after which the hour counter goes back to 0 (tick() after 23:00 gives 0:00 and the next day), ___BLANK2___ is the logical operator in isNight(), because the night is the hours from 20 to 23 and from 0 to 5, and ___BLANK3___ is the browser function that calls tick() every intervalMs milliseconds until clearInterval. A second call to start() must not start a second loop, and stop() stops it.