Sitemap

Cache Busting in React Using Webpack

Configuring Webpack to implement cache busting and create fast optimized builds in React

--

Have you ever noticed that when you open a new website, it generally takes time to load, but the newspaper website that you open daily, or your social media page, usually opens faster when you keep opening it? Well, it is due to the cache. While browsing different websites/web apps, our browsers save the cache to reduce loading time and prevent unnecessary server requests. While it is good in the scenarios mentioned above, sometimes it ends up being a nuisance. After you have deployed a few tweaks to your web app, the user might not be able to see those changes due to the cache stored in the browser. So whenever there is a change the user would have to clear his browser cache and hard reload (sometimes even multiple times) to see the new changes. But isn’t it troublesome for the user to do so?

In this article, I will go over the configuration steps of Webpack in order to prevent this from happening. As a result on a single, simple refresh, the browser would fetch new changes and reflect them for the user.

Following are the packages and their versions I have used:

  • ReactJS: v18.2.0
  • Webpack: v5.73.0
  • Webpack-dev-server: v4.9.3
  • Other packages: html-loader, babel-loader, url-loader, style-loader, css-loader, sass-loader, terser-webpack-plugin, mini-css-extract-plugin, optimize-css-assets-webpack-plugin, html-webpack-plugin, webpack-merge

You can install all required loaders and plugins from the following command:

yarn add -D css-loader style-loader babel-loader url-loader html-webpack-plugin
mini-css-extract-plugin terser-webpack-plugin
optimize-css-assets-webpack-plugin

Asset Hashing and how it Helps in Busting Cache

Asset hashing is a built-in feature of Webpack that helps us add a hash in the name of the bundle that Webpack generates. Through this technique whenever the browser encounters a new file name it discards the existing bundle it has loaded and fetches the new bundle and renders it in the browser. There were multiple kinds of hashes before Webpack 5.0 but after Webpack 5 the only available option now is contenthash which is what I have used in this implementation. The hash changes every time the code in the file (content in the bundle) changes and hence new bundle file gets loaded upon changes.

We would need to create 3 files. There will be a main webpack.config.js file in the root directory, and there will be two more files. For production configurations, we will have webpack.production.js and for development mode configurations, we will have webpack.development.js

Common Configurations

Starting off with the webpack.config.js the file we import the following methods in order to configure our file and merge it with the production or development file: webpack-merge is a tool used to merge rules from two different Webpack files for a single build. For e.g. to include common rules along with development rules, and it provides us with a merge method, path is a built-in method of Node.js that returns the path of the directory to us.

const path = require("path");
const webpack = require("webpack");
const { merge } = require("webpack-merge");
const modeConfiguration = env => require(`./webpack.${env}`)(env);
require('dotenv').config({ path: './.env' });

Next, we start setting up our module configurations. We set it up in the following method. Now all the configurations we will add will be in the object we are passing to the merge method.

module.exports = ({ mode } = { mode: "production" }) => {
console.log(`development mode is: ${mode}`);

return merge({

},
modeConfiguration(mode)
);
};

Inside the configurations, we define the entry point, which in my case is an index.js file inside the src folder, but it can vary from project to project. The entry point is from where the app is rendered to the DOM. It is the starting point of our React application. The mode keyword tells Webpack the environment we are currently using.

Next, we will make a module object, in it we define the rules for the files and the relevant loaders for it. Loaders help with preprocessing the files and converting them into browser-readable JavaScript.

mode,
entry: "./src/index.js",
module: {
rules: [
{
test: /\.html$/,
use: ["html-loader"]
},
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
loader: "babel-loader"
},
{
test: /\.css$/,
use: [
{ loader: "style-loader" },
{ loader: "css-loader" }
]
},
{
test: /\.(jpe?g|gif|png|svg)$/i,
use: [
{
loader: 'url-loader',
options: {
limit: 10000
}
}
]
}
],
noParse: [require.resolve('typescript/lib/typescript.js')],
},

The next step is to define the plugins. Plugins help us set further rules on how our files are processed. The order of plugins is important; the plugin at the end of the array would be the one that would be applied first, and its output would be fed into the second plugin, and so on. HotModuleReplacementPlugin provides a very useful tool for developers. Whenever we make a change in code, it ends up reloading our application and we lose our state. This plugin helps us fix that, and it only reloads the part that is changed. For this to work though we need to set up our dev server in a way that supports the hot loading of modules and also that we have relevant react libraries that enable this. The Define plugin helps us read and define variables from the env file at run time.

  plugins: [
new webpack.HotModuleReplacementPlugin(),
new webpack.DefinePlugin({
"process.env": JSON.stringify(process.env)
})
],

Lastly, we have to define the dev-server and the rules for it. The dev-server is used during the development phase, where you have to make changes in the code and see its results. It does not make sense to make builds again and again for this as that is a tiresome process, and hence we use a dev-server. The open prop automatically opens the localhost:8080 tab in the browser. The hot prop enables hot module replacement. Compress, as the name suggests, enables the compression of assets for faster reloading. The client overlay settings enable the errors to be shown in the browser window along with the console for easier debugging.

  devServer: {
historyApiFallback: true,
static: path.resolve(__dirname, './local-bundle'),
open: true,
hot: true,
compress: true,
client: {
overlay: {
errors: true,
warnings: false,
},
}
},

This concludes our webpack.config.js Note that all these configurations we have done uptil now are the ones that are common between both the production mode and the development mode.

Production Mode

The template for the production mode file is pretty much the same. We require some plugins to perform different functionalities. I will explain their purpose as we go on. We will write all the rules inside the object in the export method.

const path = require("path");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const TerserPlugin = require("terser-webpack-plugin");
const OptimizeCssAssetsPlugin = require("optimize-css-assets-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = () => ({
});

We defined an entry point in the common file but we didn’t define an output path for it. That was because our output configurations are different for our development mode and production mode. We will define our output for the production mode in this file now.

Get Osama Younus’s stories in your inbox

Join Medium for free to get updates from this writer.

In the output object below, I define the file name. I have appended the dynamic name and contenthash to the bundle file. This is a functionality of Webpack which will automatically add the relevant name and hash based on the file it is bundling. This is the setup to implement cache-busting, which was my main goal. Next, I have defined the path variables. It creates a folder named bundles in the root directory of my project and adds all the files there. The clean variable cleans (empties) the folder every time a new build is being created. This is essential so that our old files which are not needed anymore do not keep piling up.

output: {
filename: "[name].[contenthash].bundle.js",
path: path.resolve(__dirname, "bundles"),
clean: true
},

Next, we define our plugins and loaders. MiniCSSExtractPlugin helps us extract the CSS into a separate file and minifies it for easy browser readability. We have defined the filename with dynamic name and hash here too. This is so that the browser can reuse the styling in case there is no change in it.

The purpose of using the plugin here is that when our application is loaded, it loads the JavaScript first and then the styles are loaded. To avoid CSS loading after/with javascript we extract the CSS using this plugin. We avoid this in development mode as it is fine if it loads late in the development mode because bunding and loading CSS takes some time. This is why we have defined it as a separate rule in the production file.

Sass-loader turns SCSS into CSS and then the css-loader turns CSS into valid javascript. The style-loader turns that javascript and applies it (it gets injected into DOM with <style>. In the case of production, we have used the loader of the plugin instead of style-loader.

plugins: [
new MiniCssExtractPlugin({ filename: "[name].[contenthash].css" }),
],

module: {
rules: [
{
test: /\.sa?css$/,
use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"]
}
]
},

After that, we define our optimization object. This is I think the most important part of our configurations. In the minimizer settings, we define the plugins that will help us minify our code. Minifying is a process that helps us reduce our bundle size to make it load faster and occupy less space in the memory and build at a faster speed. For CSS files we use OptimizeCssAssetsPlugin and for our JavaScript files, we use TerserPlugin.

The HtmlWebpackPlugin helps us generate an index.html file at runtime. This is so because we are using dynamic file names with content hashes which generate at run time. We tell the HTML plugin the template it has to follow to create the file, where it needs to pick the icon to show in the browser and where to find the manifest file. We also define the minifying rules according to our liking for the HTML file.

Lastly, we define some rules for code splitting in order to help improve the performance of the application. We extract code which changes less frequently and take advantage of browser caching.

The chunks: “all” setting tells Webpack to optimise entry point loading. and minSize: 0 ensures all code is optimised despite its size.

Then in cacheGroups, we define rules that our Node Modules are extracted in separate vendor files.

optimization: {
minimizer: [
new OptimizeCssAssetsPlugin(),
new TerserPlugin(),
new HtmlWebpackPlugin({
template: "./public/index.html",
favicon: "./public/dastgyr_Icon.png",
mainifest: "./public/manifest.json",
minify: {
removeAttributeQuotes: true,
collapseWhitespace: true,
removeComments: true
}
})
],
splitChunks: {
chunks: "all",
minSize: 0,
maxInitialRequests: 10,
maxAsyncRequests: 10,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name(module, chunks, cacheGroupKey) {
const packageName = module.context.match(
/[\\/]node_modules[\\/](.*?)([\\/]|$)/
)[1];
return `${cacheGroupKey}.${packageName.replace("@", "")}`;
}
},
common: {
minChunks: 2,
priority: -10
}
}
},
runtimeChunk: "single"
},

Development Configuration

The development configuration is again defined in the very same way.

The devtool: "eval" controls how sourcemaps are generated for our bundle. It is the recommended choice by Webpack for development builds with maximum performance.

We then define our output rules and you can see here that we do not append the contenthash in the file as generating hash takes time and it is not needed during development.

Next, we define our plugins and loaders. We do not need minification in the development mode, hence we do not use any minifying plugins. We also do not minify our HTML file in the development build.

As for the loaders, as I mentioned, we do not need to use a CSS extraction plugin during development as it is fine if the CSS here loads a few moments late, so we use the simple style-loader in this case.

const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = () => ({
devtool: "eval",
output: {
filename: "[name].bundle.js",
path: path.resolve(__dirname, "bundles")
},
plugins: [
new HtmlWebpackPlugin({
template: "./public/index.html",
favicon: "./public/dastgyr_Icon.png",
})
],
module: {
rules: [
{
test: /\.sa?css$/,
use: ["style-loader", "css-loader", "sass-loader"]
}
]
}
});

Well well well, congratulations on making it through these geeky, boring configurations. If you have followed me till here, you should now have an optimised build generated through Webpack, and you would have successfully busted that harmful cache. Maybe you are thinking of yourself as the new frontend guru, probably your manager is patting you on the back and your comms team is celebrating because well one you have made user comms easier for them because well, how do you explain it to a naive user why they need to clear cache multiple times and hard refresh the browser (“what even is hard refresh’’). Well, then my friend takes notes that this is just the tip of the iceberg. Webpack is a very extensive and powerful tool which can do much more. So you certainly can bag more praise from your manager (maybe an increment?). I am no expert here, but to me, when I first came across Webpack, it seemed to be a nuisance, but then playing around with these configurations proved to be of great help once I was done with them.

--

--

Osama Younus
Osama Younus

Written by Osama Younus

Accidental Software Engineer, full time procrastinator.