Understand CSS and Javascript files Caching

I have been developing full-stack projects and the frontend (UI) using HTML, CSS, Vanilla Javascript and jQuery. As the asset files increased page loading time also increased. Users were raising this issue frequently.

To solve this issue I researched and implemented caching.

What is Caching?

Caching is the technique to store the asset files in user’s browser. This is done to avoid fetching of asset files every time the user loads the website. The asset files are fetched at the initial page load or when the browser cache is removed. After the initial load the asset files are not fetched and served directly from the browser’s cache. This reduces page loading time and improves the website’s performance.

Caching can be setup by the following config (NGINX Config):

Copy to clipboard
server {
    # Rest of the config file content

    location /[url_substring]/ {
        alias [disk_storage_path]
        expires 30d;
        add_header Cache-Control "public, max-age=2592000, immutable";
        access_log off;
    }

    # Rest of the config file content
}
  • The ` location /[url_substring ]` block will handle requests starting with a specific path.
    • Example:
      • `location /uploads/`: URLs starting with uploads after the domain will be handled by this block.
      • Requests Example: ` /uploads/image.png, /uploads/file.pdf`
  • The `alias [disk_storage_path]` line will map the upcoming requests to a directory on the disk.
    • The alias works on the basis of replacing the actual path received from the upcoming request with the path specified. 
    • Example:
      • `alias /var/www/rahulcoder`
      • Requests Example:
      • URL Actual File Path
        /uploads/file.pdf /var/www/rahulcoder/file.pdf
  • The `expires 30d;` Adds an Expires HTTP header telling browsers the file can be cached for 30 days from the present.
  • The `add_header Cache-Control “public, max-age=2592000, immutable”;` means
    • The `public` allows browser, CDN and proxy cache to store the file(s).
    • The `max-age=2592000` Cache lifetime in seconds, browser can reuse the file for 30 days without rechecking server.
    • The `immutable` tells the browser that the file won’t change in the entire cache lifetime (30 days in our scenario). Commonly used when cache-busting is implemented where the hash is replaced for the filename or appended as a query parameter when the file content changes.
    • `access_log off;` Disables logging for these requests. As static assets requests can flood logs.

Performance Impact Comparison

The metrics provided is from one of the websites I have developed and measured from the Inspect tools: Network Tab.

Terms Explanation
  • Total Requests — Number of files the browser downloaded (HTML, CSS, JS, images, fonts etc.)
  • Data Transferred — Actual data downloaded from the server. Lower means faster load.
  • Data Size — Uncompressed size of all files the browser uses after download.
  • DOM Content Load Time — Time until the page structure was ready. After this point the page starts becoming visible.
  • Window Load Event Time — Time until everything visible on the page (images, fonts, styles) was fully loaded.
  • Finish Event Time — Time until all background network activity is completed.
Without Caching
  • Total Requests: 29
  • Data Transferred: 2.1 MB
  • Data Size: 2.4 MB
  • DOM Content Load Time: 967 ms
  • Window Load Event Time: 1.02 s
  • Finish Event Time: 1.19 s
With Caching
  • Total Requests: 29
  • Data Transferred: 50.5 KB (~ 97% reduce)
  • Data Size: 2.4 MB
  • DOM Content Load Time: 360 ms (~ 63% reduce)
  • Window Load Event Time: 388 ms (~ 62% reduce)
  • Finish Event Time: 422 ms (~ 64% reduce)
By implementing caching I faced an another issue. The browser stored the old-version of asset files and served it even after the file content had changed. The end-users had to clear the cache stored by the browser, to get the latest version of the files.

 

Browsers do not have any in-built mechanism to re-fetch the file again from the web-server except the specified max-age has expired for caching or by clearing the cache. So this results in serving of old versioned files to the end-users.

 

To solve this problem I implemented Cache-Busting.

What is Cache Busting?

Cache-busting is a technique to utilise unique string of characters known as hash, in the URL by either appending it as a query parameter or replacing the filename by the hash.

The hash is generated using the file content. So when the file content changes the hash also changes. When the browsers see a different URL (as the hash is unique) it fetches the latest version of the file from the web-server.
Different Cache Busting Approaches

Approach 1 (Appending the hash/version as a query parameter):

  • Pros:
    • Simple to implement
    • No build files and mapping files generated
  • Cons:
    • Some CDNs ignore the query parameters
  • URL Example:
    • https://rahulcoder.in/public/css/styles.css?h=d2b945ec
  • Another Similar Approach:
    • A file version can be used as a query parameter
    • https://rahulcoder.in/public/css/styles.css?v=0.0.1

 

Approach 2 (Replacing the filename by the hash):

  • Pros:
    • Used in production
    • No chances of CDNs ignoring the files
  • Cons:
    • Hard to implement when compared to Approach 1
    • Extra build files and mapping files are generated
  • URL Example:
    • https://rahulcoder.in/_next/static/chunks/0wsq1823gjj9x.css
  • This approach is provided as an in-built feature in web-bundlers reducing development time and effort.

The way I used Caching and Cache-Busting

  • I am providing few code snippets from the project.
  • Tech Stack: HTML (Nunjucks – SSR [Server Side Rendering]), CSS, jQuery, Node + Express and MongoDB.
Code Flow Explanation
  • The `initAssetManifest` function runs on server start. The public folder is read and then the processing starts. The output is stored in-memory as key-value pair.
  • The file path is the key and the file path with hash is the value.
  • Template helper injected → app.locals.assetUrl = getAssetUrl makes the function available globally in all Nunjucks templates.
  • When request is received → Nunjucks renders the template, calls assetUrl(‘/public/assets/css/file.css’) → looks up the manifest → returns the path containing hash like /public/assets/css/file.css?h=[hash]
  • This step is only for the browser. As the file-content changes the hash changes and the URL changes eventually. So the browser re-fetches the file from the server.
  • The static file serving is done by NGINX.
  • The static file serving is done by NGINX. If you are new to NGINX and want to understand how it fits into a full deployment setup, refer to How to Deploy a MERN Project on AWS EC2.
assetManifest.js
Copy to clipboard
// assetManifest.js

import fs from "fs";
import path from "path";
import crypto from "crypto";
import { __dirname } from "../dirname.js";

const publicDir = path.join(__dirname, "public");

function hashFile(filePath) {
	const content = fs.readFileSync(filePath);
	return crypto.createHash("md5").update(content).digest("hex").slice(0, 8);
}

function buildManifest(dir, base = "") {
	const manifest = {};
	const entries = fs.readdirSync(dir, { withFileTypes: true });

	for (const entry of entries) {
		const relativePath = path.join(base, entry.name);
		const fullPath = path.join(dir, entry.name);

		if (entry.isDirectory()) {
			Object.assign(manifest, buildManifest(fullPath, relativePath));
		} else if (/\.(css|js|png|jpg|jpeg|svg|webp|ico|woff2?|ttf|eot|otf)$/.test(entry.name)) {
			const hash = hashFile(fullPath);
			const keyPath = relativePath.split(path.sep).join("/"); // ← always forward slashes
			manifest[`/public/${keyPath}`] = `/public/${keyPath}?h=${hash}`;
		}
	}

	return manifest;
}

let manifest = {};

export function initAssetManifest() {
	manifest = buildManifest(publicDir);
	console.log(`Asset manifest built: ${Object.keys(manifest).length} files`);
}

export function getAssetUrl(logicalPath) {
	return manifest[logicalPath] ?? logicalPath;
}
server.js
Copy to clipboard
// server.js
import "dotenv/config";
import express from "express";
import nunjucks from "nunjucks";
import router from "./routes/routes.js";
import { getAssetUrl, initAssetManifest } from "./utils/assetManifest.js";

const app = express();
const PORT = process.env.PORT;

app.use(express.json({ limit: "10MB" }));
app.use(express.urlencoded({ extended: false, limit: "10MB" }));

nunjucks.configure("./views", {
	autoescape: true,
	express: app,
	watch: false,
});

initAssetManifest();

// Inject helper into all Nunjucks templates
app.locals.assetUrl = getAssetUrl;

app.set("trust proxy", true);
app.set("view engine", "html");

app.use("/panel", router);

app.listen(PORT, () => console.log(`Server running at port ${PORT}`));
layout.html
Copy to clipboard
<!-- views/layout.html -->

<head>
	<meta charset="utf-8" />
	<meta http-equiv="X-UA-Compatible" content="IE=edge" />
	<meta name="viewport" content="width=device-width, initial-scale=1" />
	<title>Index Page</title>

	<!-- Google Webfonts -->
	<link rel="stylesheet" href="{{ assetUrl('/public/fonts/fonts.css') }}" />

	<link rel="stylesheet" href="{{ assetUrl('/public/assets/css/rag-index-styles.css') }}" />

	<!-- Font Awesome -->
	<link rel="stylesheet" href="{{ assetUrl('/public/assets/font-awesome/css/all.min.css') }}" />
</head>
output.json
<!-- Output from the assetManifest.js file console.log -->
{
  '/public/assets/404.png': '/public/assets/404.png?h=427d9201',
  '/public/assets/bootstrap.min.css': '/public/assets/bootstrap.min.css?h=6032a131',
  '/public/assets/bootstrap.min.js': '/public/assets/bootstrap.min.js?h=71dd3737',
  '/public/assets/chartjs.min.js': '/public/assets/chartjs.min.js?h=50cf2ab0',
  '/public/assets/rag-index-styles.css': '/public/assets/rag-index-styles.css?h=c7a169aa'
}

Why I Chose Approach-1 Over Approach-2?

  • I was the sole developer on the project, so keeping the setup simple was a priority.
  • The project had no CDN links, which is the only real risk with Approach 1.
  • Approach 2 requires a build process with mapping files — extra overhead that made no sense for a project of this scale.

Web Bundlers

  • Web bundler is a development tool that runs on the built time. The following things are done by the web bundler:
    • Generating unified and minfied version of asset files. Less file size reduces the time to fetch the asset file
    • Replaces the actual filename of the asset files by the hash to ensure the file content can be cached and fetched when the file content changes. (Requires enabling of caching)
  • Few popular examples of web-bundlers are Vite, Webpack and Parcel.

Conclusion

Caching can effectively reduce page loading time and improve page performance if setup appropriately with cache-control rules and cache-busting.

Before setting up caching, make sure HTTPS is enabled on your server. Follow How to Install SSL Certificates on Ubuntu with Certbot to set that up.

You can connect with me through my socials for any discussions.