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 can be setup by the following config (NGINX Config):
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`
- Example:
- 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)
What is Cache Busting?
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
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
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}`));
<!-- 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 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.