Asset fingerprinting and the preload response header in 11ty

This blog post will be building on a number of blog posts that I wrote earlier in the year. These were the posts:

Some insights from my earlier posts may carry over here, so check them out for overlap or a fuller view of my custom CSS pipeline for 11ty.

In this post, I’ll improve performance by adding the preload technique to my blog. First, let’s look at what it is.

Preload basics

In a standard web page load, once requested, the server sends over the HTML document as well as numerous response headers too. The HTML is progressively served to the browser, and it is only when the parser encounters the standard <link src="example.css" rel="stylesheet"> link that the browser requests the CSS file from the server. Therefore, best practice is to place this CSS link as close to the top of the <head> tag as you can. This ensures that the browser sees it quickly and thus starts downloading it as soon as it can. But what if you could give the browser a "hint" as to what is coming up in the document? This is where the Preload hint functionality comes in.

The Preload hint is essentially saying to the browser:

I know you're busy doing other things at the moment, but you should also know that you absolutly will be requiring this file soon in the page load. So stick it at the top of your list to download as soon as you can.

It's important to realise that this is only a "hint", not a mandatory instruction. The browser may choose to entirely ignore it, if for example it has already parsed and discovered the file you wish for it to preload. There are 2 ways in which you can implement a preload.

This is probably the easiest way to add a preload to a website. Stick it in the <head>:

<link rel="preload" href="style.css" as="style" />
<link rel="preload" href="main.js" as="script" />

This method ensures that the browser is told about what other resources to load along with the HTML document in the form of a response header from the server. The above "Link in the head" functionality looks like this as a response header:

Link: </style.css>; rel=preload; as=style
Link: </main.js>; rel=preload; as=script

OR

Link: </style.css>; rel=preload; as=style, </main.js>; rel=preload; as=script

Both headers give the exact same functionality, it just comes down to readability. I don't believe the single line version give any performance advantages, especially when any form of header compression is applied, e.g. hpack for HTTP/2 or qpack for HTTP/3. But please do let me know if this assumption I'm making isn't true!

In both instances above, we are telling the browser to preload the page’s CSS and JavaScript because they will be required to render the page. You may notice that I phrased it as “will be required”. This is deliberate because it is far too easy to abuse the preload functionality. If you instruct the browser to preload everything, you will likely harm web performance instead of improving it. So only preload assets that are genuinely needed for the page to render. Otherwise, you risk wasting bandwidth on unnecessary resources and slowing down the rendering process. I know Firefox warns you in the DevTools console if an asset has been preloaded but not used during a certain time period, other browsers may do this as well. So always check your browser console for similar messages.

Preload and fingerprinting

There is a small added challenge when using asset fingerprinting with the preload functionality. Since the filename of the CSS or JavaScript changes completely whenever the file contents change, you cannot simply preload index.css or main.js. They will instead be renamed to something like index-362ccd3816.css or main-2fc0e9cad0.js. These are just example names, but the important point is that the file names are unpredictable and will change with each build, assuming the content of the files change. Since nobody wants to update a preload reference by hand every time a file changes, this is where a bit of 11ty scripting magic steps in to save the day.

The Code

In order to roll this functionality into my 11ty build, I created a helper file in my _helpers directory in the root of my blog. This is called header-generator.js. Imaginative name, huh! All functionality related to the header generation will be contained within this file.

It is then imported into my eleventy.config.js like so:

import { generatePreloadHeaders } from './_helpers/header-generator.js';

Now, I only want this code to run in production, and after the 11ty build completes, so I added the following later in the config:

if (IsProduction) {
	eleventyConfig.on('eleventy.after', generatePreloadHeaders);
}

Hopefully, this code is fairly self-explanatory. I'm hooking into the eleventy.after event, which is the point at which my CSS has been Brotli compressed and fingerprinted, and the Link Header is ready to be generated and added to my Cloudflare Pages _headers file (documentation here) before the _site is built. Below is the complete header-generator.js file I am using, with detailed comments to make it easier to follow:

// standard node library imports
import fs from 'fs';
import path from 'path';

// This script generates preload headers for fingerprinted CSS files in the _site/css directory
// and adds them to the global section of the _headers file (/*) in the _site directory.
// It prefers Brotli-compressed files (e.g. *.css.br rather than *.css) if available.
export function generatePreloadHeaders() {
	// Log the start of the process
	console.log('Generating preload headers for CSS files...');
	// This is my CSS directory for my blog
	const cssDir = path.join('./_site', 'css');

	// Check if CSS directory exists
	if (!fs.existsSync(cssDir)) {
		console.log('CSS directory not found, skipping header generation');
		return;
	}

	// Find fingerprinted CSS files (both .css and .css.br). We prefer .css.br if available.
	// Fingerprinted files match the pattern index-[hash].css or index-[hash].css.br
	const cssFiles = fs.readdirSync(cssDir)
		.filter(file => {
			// Match files like index-b9fcfe85ef.css.br or index-b9fcfe85ef.css
			return file.match(/^index-[a-f0-9]{10}\.css(\.br)?$/);
		});

	// Nothing found so exit
	if (cssFiles.length === 0) {
		console.log('No fingerprinted CSS files found, skipping header generation');
		return;
	}

	// Sort to prefer *.br files over *.css files
	// (compression is done via the zlib library in another helper file)
	// both .css and .css.br files exist in the same folder with the same file hash
	// The hash is generated from the unminified and uncompressed CSS file)
	cssFiles.sort((a, b) => {
		// If a is .br and b is not, a comes first
		if (a.endsWith('.br') && !b.endsWith('.br')) return -1;
		// If b is .br and a is not, b comes first
		if (!a.endsWith('.br') && b.endsWith('.br')) return 1;
		return 0;
	});

	// Take the first (preferably .br) file
	const cssFile = cssFiles[0];

	// Construct the path for the Link header
	const cssPath = `/css/${cssFile}`;

	console.log(`Found CSS file: ${cssFile}`);
	// Now we need to read the existing _headers file, add the preload header to the global section (/*),
	// and write it back
	try {
		// Read the source headers file
		const sourceHeadersPath = path.join('./public', '_headers');
		// Set our target headers file
		const targetHeadersPath = path.join('./_site', '_headers');

		// Check if source _headers file exists
		if (!fs.existsSync(sourceHeadersPath)) {
			console.log('Source _headers file not found');
			return;
		}

		// Read the existing headers content
		let headersContent = fs.readFileSync(sourceHeadersPath, 'utf8');

		// Create the preload header
		// Note: 'nopush' prevents Cloudflare from doing HTTP/2 server push
		const preloadHeader = `  Link: <${cssPath}>; rel=preload; as=style; nopush`;

		// Find the global /* rule and add the preload header to it
		// Look for the line that just contains "/*" which is the global section
		const lines = headersContent.split('\n');
		let globalSectionIndex = -1;
		let nextSectionIndex = -1;

		// Find the global section (line that starts with just "/*")
		for (let i = 0; i < lines.length; i++) {
			if (lines[i].trim() === '/*') {
				globalSectionIndex = i;
				break;
			}
		}

		// Find the next section (line that starts with a path)
		if (globalSectionIndex !== -1) {
			for (let i = globalSectionIndex + 1; i < lines.length; i++) {
				if (lines[i].trim() !== '' && !lines[i].startsWith('  ')) {
					nextSectionIndex = i;
					break;
				}
			}
		}
		// If we found the global section, proceed to add or update the Link header
		if (globalSectionIndex !== -1) {
			// Check if a Link header already exists in the global section
			let linkHeaderExists = false;
			// Iterate through the lines in the global section
			for (let i = globalSectionIndex + 1; i < (nextSectionIndex === -1 ? lines.length : nextSectionIndex); i++) {
				// Check for the existance of the Link header
				if (lines[i].includes('Link:')) {
					// Replace existing Link header
					lines[i] = preloadHeader;
					// The Link header exists and has been updated
					linkHeaderExists = true;
					break;
				}
			}

			// If no Link header exists, add one
			if (!linkHeaderExists) {
				// Find the last header line in the global section
				let lastHeaderIndex = globalSectionIndex;
				// Iterate until the next section or end of file
				for (let i = globalSectionIndex + 1; i < (nextSectionIndex === -1 ? lines.length : nextSectionIndex); i++) {
					if (lines[i].trim() !== '' && lines[i].startsWith('  ')) {
						lastHeaderIndex = i;
					}
				}
				// Insert the Link header after the last header
				lines.splice(lastHeaderIndex + 1, 0, preloadHeader);
			}
			// rejoin the modified _headers file
			headersContent = lines.join('\n');
		} else {
			console.log('Could not find global section in _headers file');
			return;
		}

		// Write the updated headers to the _site directory before deployment to Cloudflare pages
		fs.writeFileSync(targetHeadersPath, headersContent);

		console.log(`Generated preload header: Link: <${cssPath}>; rel=preload; as=style; nopush`);

	} catch (error) {
		console.error('Error generating preload headers:', error);
	}
}

For a cleaner version without comments, I’ve uploaded the code to a Gist. Find the code Gist here.

The Cloudflare _headers file

This setup is currently working really well, the only minor "issue" that doesn't sit right with me presently is the fact that the Link header sits on the global header path (/*) in the _headers file. This means the Link header is added to all assets served from my blog.

As far as I know, this shouldn't cause any issues, as browsers will just ignore it if on a file type that doesn't support it. But I would like to rectify this in the future.

In my testing with the Cloudflare _headers file, once a header is set in Cloudflare Pages it cannot be removed or overwritten. The only "fixes" I’ve found for this are:

  1. Use a response header transform rule in the Cloudflare dashboard to remove the Link header from all other file types served except CSS.
  2. Look into a Cloudflare Workers solution to examine the server responses at "the edge" and remove them that way.

I will eventually move forward with option 1 as it looks to be the most straight-forward way to do it.

I've mentioned this "minor issue" in the blog post, simply to highlight the fact about not being able to remove headers using the _headers file once they have been set. If anyone knows how to do this using only the _headers file, please let me know. I’d love to learn how.

Summary

All this is now live on this very blog, so using my custom CSS pipeline with 11ty, I now have the following happening before deployment to live:

  1. CSS minified and Brotli compressed to level 11 (highest).
  2. CSS Asset fingerprinting to allow for long life cache-control headers, including immutable.
  3. Preloading of the CSS file to reduce the discovery time and improve page performance.

On a side note: This is probably one of the fastest (and shortest) blog posts I've written in a while! I knew I could do it! 🤣 I hope you found it as enjoyable to read as I did to write. As always, feedback and post corrections are welcome. Spot anything wrong? Please do let me know.


Post changelog:

  • 02/09/25: Initial post published.

Webmentions

No mentions yet.

A webmention is a way to notify this site when you've linked to it from your own site.