Production engineering guide for bundle optimization covering patterns, implementation strategies, and operational best practices.
Bundle optimization is the process of reducing the size, number, and load time of frontend JavaScript and CSS bundles to improve perceived performance, especially on slow networks and low-end devices. It matters most during the initial page load and when users navigate across routes in a single-page application.
The primary goal is to minimize the amount of JavaScript and CSS transferred from the server to the client. This involves shrinking code, eliminating unused modules, and managing asset delivery efficiently.
Tree shaking eliminates dead code—code that is declared but never used. It relies on static module analysis to determine what parts of a library are actually consumed.
In a project using ES modules, tree shaking is enabled by default in build tools like Webpack, Vite, and Rollup. The key configuration is setting module: 'esm' in package.json and enabling sideEffects in package.json.
{
"name": "my-app",
"module": "src/index.js",
"sideEffects": [
"src/styles/*.css",
"src/utils/**/*.js"
]
}
A module is considered side-effect-free if it does not modify global state, such as adding event listeners or mutating a global object. When a module has sideEffects: false, the bundler can safely remove it if no other module imports it.
Code splitting divides the bundle into smaller chunks, enabling lazy loading and reducing the initial payload.
Splitting by entry points defines the main bundles. For example, a React application with multiple pages might define separate entry points:
// webpack.config.js
const path = require('path');
module.exports = {
entry: {
home: './src/pages/home.js',
profile: './src/pages/profile.js',
admin: './src/pages/admin.js'
},
output: {
filename: '[name].[contenthash].js',
path: path.resolve(__dirname, 'dist')
}
};
This generates home.abc123.js, profile.def456.js, and admin.ghi789.js. The contenthash ensures cache invalidation when content changes.
Dynamic imports enable runtime code splitting. Use import() to split modules on-demand.
// In a React component
function Dashboard() {
const [data, setData] = useState(null);
useEffect(() => {
import('./charts').then(({ renderCharts }) => {
renderCharts(data);
});
}, [data]);
return <div>Dashboard with charts</div>;
}
This generates a separate chunk, charts.abc123.js, which is fetched only when the Dashboard component is rendered.
Compression reduces the size of bundles using algorithms like Gzip and Brotli.
Gzip is the default compression method in HTTP. Configure it in the server:
location / {
gzip on;
gzip_types text/css text/javascript application/javascript application/json;
gzip_vary on;
gzip_min_length 1024;
}
The gzip_min_length ensures only files larger than 1KB are compressed. Smaller files are served uncompressed.
Brotli offers better compression than Gzip. Enable it in the server:
# nginx.conf
brotli on;
brotli_comp_level 6;
brotli_types text/css text/javascript application/javascript application/json;
brotli_static on;
Brotli is particularly effective for JavaScript and CSS files. The brotli_static directive serves pre-compressed .br files alongside .gz.
Optimizing bundle load order ensures that critical code is available early, minimizing time to interactive.
Critical CSS is the minimal CSS required to render the above-the-fold content. Inline it directly in the HTML <head>.
Use PurgeCSS or Critical to extract and inline critical styles.
// critical.js
const critical = require('critical');
critical.generate({
base: 'dist/',
src: 'index.html',
dest: 'dist/critical.html',
width: 1200,
height: 1200,
css: ['dist/styles.css']
});
The resulting critical.html includes the critical CSS inline, reducing render-blocking requests.
Use <link rel="preload"> and <link rel="prefetch"> to control when resources are fetched.
<!-- Preload critical assets -->
<link rel="preload" href="styles.css" as="style">
<link rel="preload" href="main.js" as="script">
<!-- Prefetch non-critical assets -->
<link rel="prefetch" href="charts.js">
<link rel="prefetch" href="admin.js">
preload fetches resources early, while prefetch fetches them during idle time. Use as to hint the resource type, allowing the browser to prioritize downloads.
Module federation enables sharing code between multiple applications, reducing duplication.
In a Vite or Webpack setup, configure module federation in the webpack.config.js:
// webpack.config.js
const { ModuleFederationPlugin } = require('webpack');
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'dashboard',
filename: 'remoteEntry.js',
exposes: {
'./Dashboard': './src/Dashboard'
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true }
}
})
]
};
This exposes a Dashboard component and shares react and react-dom across multiple apps. The consuming app fetches remoteEntry.js and dynamically loads shared modules.
Effective caching reduces repeat requests and improves performance over time.
Use content-based hashing to ensure clients always use the latest version of a file.
// webpack.config.js
output: {
filename: '[name].[contenthash].js',
chunkFilename: '[name].[contenthash].chunk.js'
}
When the content of main.js changes, the filename becomes main.abc123.js. The browser caches it for a long time, and only requests the new version when the content hash changes.
HTTP/2 enables server push, where the server sends resources before the client requests them.
In Nginx, enable server push with:
# nginx.conf
location / {
http2_push /styles.css;
http2_push /main.js;
http2_push /vendor.js;
}
The server pushes styles.css, main.js, and vendor.js immediately after sending the HTML. The client receives them before parsing the HTML.
Service workers cache assets for offline use and fast reloads.
// service-worker.js
const CACHE_NAME = 'app-v1';
const urlsToCache = [
'/',
'/index.html',
'/styles.css',
'/main.js',
'/vendor.js'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache))
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
return response || fetch(event.request);
})
);
});
This service worker caches the core assets and serves them from the cache on subsequent visits.
Tree shaking fails silently when a module is imported but not used. The most common cause is dynamic imports of modules with side effects.
Example:
// utils.js
export function log(message) {
console.log(message);
}
export function format(date) {
return date.toLocaleDateString();
}
// In a component
import('./utils').then(utils => {
utils.format(new Date()); // only uses format, not log
});
The log function is tree-shaken away even though it is imported. To fix, mark utils.js as having side effects:
{
"sideEffects": ["src/utils.js"]
}
splitChunks and optimization in WebpackIn Webpack, splitChunks and optimization configurations often overlap.
// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]react/,
name: 'vendors-react',
chunks: 'all'
},
common: {
name: 'common',
chunks: 'all',
minChunks: 2
}
}
}
}
};
A common mistake is assuming cacheGroups applies only to splitChunks. But optimization also applies to splitChunks. Misunderstanding this leads to unexpected chunks.
preload on Dynamic ImportsWhen using import() for lazy loading, the browser does not automatically preload the chunk. The chunk is fetched only when needed.
To improve this, manually add preload:
// In a React component
function Profile() {
useEffect(() => {
const module = import('./profile-charts').then(({ render }) => {
render();
});
// Preload the chunk
module.then(() => {
const link = document.createElement('link');
link.rel = 'preload';
link.href = './profile-charts.js';
link.as = 'script';
document.head.appendChild(link);
});
}, []);
}
Without this, the chunk is fetched only on-demand, missing a chance for early fetching.
Brotli has better compression but larger CPU cost during encoding. Gzip is faster but less effective.
Use Brotli for assets that are served frequently and benefit from smaller size, such as JavaScript, CSS, and HTML.
Use Gzip for assets that change often and are served on low-end devices, where CPU is limited.
Serve both formats and use Accept-Encoding headers to select the best:
# nginx.conf
location / {
add_header Vary Accept-Encoding;
gzip on;
brotli on;
brotli_types text/css text/javascript application/javascript application/json;
}
The client can send Accept-Encoding: gzip, br and the server responds with the best format.
node_modules is not split properly, vendor.js becomes huge and uncacheable. Use splitChunks with chunks: 'all' and minChunks: 2.mini-css-extract-plugin in Webpack.Cache-Control: public, max-age=31536000 to static assets.preload timing: preload is added but not in the <head>. Ensure the <link> elements are inserted early in the HTML.import() is called but the chunk is not fetched. Check that the chunk is served and that the entry point is correct.Bundle optimization is not a one-time task but an ongoing process. It requires attention to build configuration, content delivery, and runtime behavior. The most common failure is assuming that "bundling" means "done." But with evolving user expectations and network conditions, optimization must be continuously re-evaluated and tuned.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy