# Nooshu - Matt Hobbs - Matt Hobbs is a Frontend Engineering Manager from Oxfordshire, UK. Blog on web performance, frontend development, and engineering leadership. ## This is the website of Matt Hobbs, who is a Frontend Engineering Manager from Oxfordshire, UK. URL: https://nooshu.com Full archive (all posts) --- Start: How a Simple CSP Tweak Turned into an AI Project Published on: 20 June 2026 https://nooshu.com/blog/2026/06/20/how-a-simple-csp-tweak-turned-into-an-ai-project/ Main Content: Introduction In this post, I’ll tell the story of how a stray semicolon led me to build an AI-powered tool to make creating Content Security Policies (CSPs) a bit less painful for developers. I’ve written about CSPs on this blog before: December 2024: Securing your static website with HTTP response headers February 2025: Configuring your Content-Security-Policy on your development environment in 11ty To quote from my 2024 post: The Content Security Policy (CSP) header is a security feature that protects web applications from attacks like Cross-Site Scripting (XSS) and code injection by controlling the sources from which browsers can load and execute content. If there's only a single response header you implement today, then make it the CSP Response header. The post has a few more details, so if you are interested you can find the Content-Security-Policy (CSP) section here. As mentioned in the post, there are already a huge number of tools available on the web for creating CSP’s, but none of them really fit my needs. So, I decided to write my own tool using cursor.ai and host it on the web for everyone to evaluate and use. Note: I've included my referral link so any readers can get 50% off their first month subscription. It is called CSP Playground⁠. Despite the name, writing a CSP is rarely described as “fun”, and “playground” may be overselling the experience somewhat. It seemed as sensible a name as any, though. Besides, the domain wasn’t taken and only cost £9, which is cheap enough to make almost any naming decision seem justified! I briefly considered splashing out on a .security top-level domain (TLD), until Cloudflare Registrar reminded me that the privilege would cost only £1,515 per year. I suspect telling my wife that we’ve cancelled our summer holiday plans so I can own a particularly niche bit of the internet would have tested the “for richer or poorer” part of our vows rather more thoroughly than intended. 😬 Problem If any readers have tried to write a CSP “manually”, you will understand how painful it really is! Understanding the very particular syntax is challenging. Although CSP itself does not have to be written on a single line conceptually, when the header is sent over the network, it must not contain any arbitrary line breaks, else it will be classed as invalid. And don’t even get me started on the use of ;, one misplaced semicolon and your browser console will throw a very red looking hissy fit! There are whole RFC’s dedicated to the specifics of HTTP header fields, that I won’t even pretend to understand! Linked below for a little light reading, if you are struggling to sleep! RFC 7231 - Hypertext Transfer Protocol (HTTP/1.1): Semantics and Content RFC 9110 - HTTP Semantics RFC 9112 - HTTP/1.1 Anyway, as I always do, I’ve wandered off on a bit of a tangent. What I’m basically saying is writing CSP’s is complicated and frustrating (especially for a terrible typist like myself!), So why not make life easier and spend a couple of days in "AI world” and build a tool to make your life easier? You never know, it may even help others too! Features Form-based editor for every standard CSP directive: No hand-editing of semicolon syntax Import from URL: Fetch a policy from headers or meta tags, or detect when none exists Paste headers or policy: Paste a full HTTP response header block or a single Content-Security-Policy line; the CSP is extracted in the browser and used to pre-fill the form Real-time security score: Including actionable recommendations that navigate you instantly to the exact directive Nonce and hash helpers: Generate values and copy ready-to-paste HTML snippets One-click export: Policy, header line, or server config for Apache, Nginx, Caddy, Cloudflare, Netlify, Vercel, and more Report-only mode: Option to output the CSP using Content-Security-Policy-Report-Only. Allowing for testing before enforcement MDN links and inline help: Every directive and sandbox flag links directly to the relevant MDN content Privacy focused by design: No accounts, no tracking, and no database. Nothing is stored because there’s nowhere to store it Runs mostly in the browser: Policy editing and paste import stay local; the only server-side processing happens when you explicitly import a URL to fetch your site’s CSP Free and open-source: MIT licensed at https://csp-playground.dev/, code available on GitHub Clean design Built on the fantastic utopia.fyi Fluid Design CSS grid built by Clearleft Audit Start from a real policy instead of a blank form. Enter a URL and CSP Playground will retrieve the CSP from the response headers or a tag. Alternatively, you can paste the headers or the policy string directly. Everything stays in the browser, with the exception of URL lookups. Whichever approach you choose, the editor is pre-populated so you can quickly see what is being enforced. If the lookup finds problems (duplicates, deprecated directives, odd formatting), it flags them and suggests fixes. For sites where there is no CSP at all, the tool does not leave a user at in the lurch: it points you to a short guide on why a policy matters and how to start safely, so auditing either an existing policy or a blank slate becomes the first step toward a stronger CSP. Build Whether you are starting from scratch or updating an existing policy, you can work through each CSP directive using a structured form. Add sources without worrying about syntax, generate (placeholder) nonces and hashes for inline scripts and styles, and copy ready-to-use HTML snippets. Every directive links directly to MDN, so the documentation is always one click away when you need it. Improve As you edit, CSP Playground scores your policy in real time and highlights improvements you can make. Recommendations are practical, and show the expected score increase, simply click on the recommendation, and it takes you straight to the relevant directive. You can also see your potential score if you applied every suggestion. This makes it a whole lot easier to focus on the changes that matter most, even if you’ve never written a CSP before. Deploy When you are happy with your shiny new Content Security Policy, CSP Playground generates the full header and provides config snippets for web servers and hosting platforms: Apache Nginx Caddy LiteSpeed Microsoft IIS Netlify Cloudflare Pages Vercel Traefik Envoy more can be added on request, just create an issue You can also toggle a setting so that the server setup only attaches the CSP header to HTML responses, cutting down on repeated headers across all static assets, where they aren’t required. Lastly, you can switch to Report-Only mode to test changes before enforcement, with links to MDN guidance to help you roll out your new (or updated) CSP safely. Result So, the first test for the tool was the CSP for this very website, after all it’s the reason I built it! As mentioned at the start of the post, the first result of my initial iteration of the tool caught a stray. It must have been live for quite a while, as I haven’t touched the CSP since February 2025. Thankfully, it wasn’t serious, hence why it went unnoticed. The validator picked it up and changed this: Content-Security-Policy: base-uri 'self';child-src 'self';connect-src 'self' https://challenges.cloudflare.com/;default-src 'none';img-src 'self' https://v1.indieweb-avatar.11ty.dev/;font-src 'self';form-action 'self' https://webmention.io;frame-ancestors 'none';frame-src 'self' https://player.vimeo.com/ https://www.slideshare.net/ https://www.youtube.com/ https://giscus.app/ https://challenges.cloudflare.com/;manifest-src 'self';media-src 'self';object-src 'none';script-src 'self' 'inline-speculation-rules' https://ajax.cloudflare.com https://giscus.app/ https://challenges.cloudflare.com/;script-src-elem 'self' 'inline-speculation-rules' https://ajax.cloudflare.com https://giscus.app/ https://challenges.cloudflare.com/;style-src 'self' 'unsafe-inline' https://giscus.app/;worker-src 'self'; To this: Content-Security-Policy: base-uri 'self';child-src 'self';connect-src 'self' https://challenges.cloudflare.com/;default-src 'none';img-src 'self' https://v1.indieweb-avatar.11ty.dev/;font-src 'self';form-action 'self' https://webmention.io;frame-ancestors 'none';frame-src 'self' https://player.vimeo.com/ https://www.slideshare.net/ https://www.youtube.com/ https://giscus.app/ https://challenges.cloudflare.com/;manifest-src 'self';media-src 'self';object-src 'none';script-src 'self' 'inline-speculation-rules' https://ajax.cloudflare.com https://giscus.app/ https://challenges.cloudflare.com/;script-src-elem 'self' 'inline-speculation-rules' https://ajax.cloudflare.com https://giscus.app/ https://challenges.cloudflare.com/;style-src 'self' 'unsafe-inline' https://giscus.app/;worker-src 'self' It took me a little while to spot the difference because the validator simply reported a "semicolon change". If you are able to notice these small changes straight away, congratulations, you’re more observant than I am! For any readers wondering the only difference is that the trailing semicolon in the second version has been removed. It’s actually entirely optional and has no effect on how the CSP behaves. I admit it’s hardly a groundbreaking discovery, but it was reassuring to see that the validation functionality was doing exactly what it should. The next changes it recommended made more of an impact and improved my CSP score. The initial score was 79%. Not bad, but we can do better with only 2 minor changes: trusted-types (TT) I must admit I’d never heard of this directive until the tool recommended it. It's only very recently (February) that it has been marked as Baseline in the MDN documentation due to broad cross browser support across all modern browsers. It was actually Mozilla who were dragging their feet on this directive, but thankfully changed their stance on it back in 2023. Full support in Firefox was released in version 148 (released 24 February 2026). If you’re curious about this directive, here’s an ELI5 explanation: Think of an Aeroplane at an airport. Before TT: Anybody could walk straight onto the plane. After TT: Everybody must: Go through security. Have their passport checked. Receive an approved boarding pass. Only passengers (or potentially executable content in this analogy) with a valid boarding pass are allowed onto the plane. Trusted Types are the boarding pass. For readers who are older than 5, you can read all about the Trusted Types API in great depth on MDN. upgrade-insecure-requests This directive is pretty self-explanatory given the name. upgrade-insecure-requests instructs a browser to automatically upgrade HTTP requests to HTTPS before loading resources. Resources that do not support HTTPS will fail to load, although this is very rare on the modern web. By simply adding: trusted-types 'none'; upgrade-insecure-requests to my CSP, it bumped the score from 79% up to 87%! A respectable gain for what was, in the grand scheme of things, barely any work! Adding upgrade-insecure-requests improves protection against mixed content by upgrading HTTP resources to HTTPS. Meanwhile, trusted-types 'none' does not actually enforce Trusted Types, but it does make the decision to not use them very explicit. For the moment I have simply disabled the use of Trusted Types in the CSP, but in the future I will likely enable enforcement via the use of: require-trusted-types-for 'script'; trusted-types default; Once enforced this will bring the sites CSP score up to 95%! 🎉 Update 1: I tested require-trusted-types-for 'script'; with 'trusted-types default;, but it breaks a few scripts across the site, so have reverted to trusted-types 'none';. I will update once I have resolved the issues. It just goes to show how much testing you need to do when you change your site's CSP! Use Content-Security-Policy-Report-Only if in doubt! Update 2: It turns out these errors occur because some of these scripts use legacy code, such as: // or element.innerHTML = `

${text}

`; element.innerHTML = '

' + text + '

'; To properly resolve these issues, you'd need to refactor the scripts to use more standard DOM manipulation methods, like: const p = document.createElement('p'); p.textContent = text; element.appendChild(p); The problem comes when you don't "own" these scripts, e.g. a 3rd-party script. It's just not practical to update these scripts without the original author(s) being involved, as any new version of the script will overwrite the changes. There is a workaround: // run this code immediately using an Immediately Invoked Function Expression (IIFE) (function () { // check see if the browser supports the trustedTypes API if (window.trustedTypes?.createPolicy) { // Create a Trusted Types policy called "default" policy for the browser to use e.g. `trusted-types default;` trustedTypes.createPolicy('default', { // allow all HTML through e.g. I trust every HTML string. (this is bad!) createHTML: (string) => string, }); } })(); It is critical that this code executes before the browser tries to execute innerHTML, as without the policy the code will fail. Unfortunately, this code has a major security implication: XSS vulnerabilities are no longer prevented. Given this implication, I'm going to stick to trusted-types 'none'; for the moment, or at least until I look into using DOMPurify to "enable" XSS protection. Update 3: I have tried a solution using ) on my preview environments, and I'm still seeing a host of little issues like missing images in some functionality, so I'll stick with trusted-types 'none'; for the moment as it's the only otion that works at the moment. It's unfortunate that it really isn't adding much in terms of site security, and it's only there for the "CSP Score", but on the positive side it's been another CSP learning curve for me! So when using the trusted-types directive, you should definetly expect issues with JavaScript that uses legacy code! It could be a while before anyone gets to roll out this directive without issues! Update 4: I have tried a solution using DOMPurify on my preview environments, and I'm still seeing a host of little issues like missing images in some functionality. So, I'll stick with for the moment, as it's the only option that works at the moment. It's unfortunate that it really isn't adding much in terms of site security, and it's only there for the "CSP Score", but on the positive side it's been another CSP learning curve for me! So when using the trusted-types directive, you should definitely expect issues with JavaScript that uses legacy code! It could be a while before anyone gets to roll out this directive without issues! Summary I promised myself that I wouldn’t make this blog post an epic read this time, so fingers crossed I have kept my promise! If you happen to be interested in my “strategy” and opinion (for what it's worth) on AI assisted coding off the back of this post, I’ve wrote all about it here in my FractalAI: Generating Infinity in the Browser post from April. Lastly, you can find CSP Playground here, and the GitHub repo for the code here. PRs and Issues welcome! I hope you find this little tool useful! I’ve enjoyed building it and learning a little more about the world of CSPs, and Trusted Types. As always, thanks for reading, feedback and comments are welcome. You can contact me here. Post changelog: 20/06/26: Initial post published. 20/06/26: Reverted require-trusted-types-for 'script'; with trusted-types default; to trusted-types 'none'; because it broke a number of scripts on the site! 20/06/26: Added information about the require-trusted-types-for 'script'; workaround before 3rd-party scripts are updated to use non-legacy DOM methods. Sticking with trusted-types 'none'; for the moment. 21/06/26: I tried to resolve issues with require-trusted-types-for 'script'; with trusted-types default; by using DOMPurify, but there are still other problems that pop up, so still sticking with trusted-types 'none';. I'll chalk this one up to a learning exercise, and leave it as is for now! After all, this post was only supposed to be about CSP Playground, not the Trusted Types directive. --- End: How a Simple CSP Tweak Turned into an AI Project --- Start: FractalAI: Generating Infinity in the Browser Published on: 21 April 2026 https://nooshu.com/blog/2026/04/21/fractalai-generating-infinity-in-the-browser/ Main Content: Why I Built FractalAI Although AI has been a concept that has been discussed for the past 80 years (Thanks to Alan Turing, and many others), it’s only in the past 5 to 10 years that it has become vastly more powerful. It is currently the hot topic in pretty much every industry on the planet! Software development in particular has already changed drastically, with new tools and AI Models popping up every week. You only have to look at the Stack Overflow Developer Survey 2025 to see how common its usage is in Software development: 84% of developers are using or planning to use AI 51% of professional developers use it daily 61% of professional developers look at it favourably 35% of developers visit Stack Overflow due to AI-related issues So like it or not, AI is here, and it is here to stay. So to keep up with "the pack” it’s probably a good idea to start learning how to use it, (and more importantly) where to be cautious! The Constraint: 100% AI-Generated Code To get to grips with AI and its current limits, I decided to set myself a task: build a new hobby project using only AI. All code, all functionality, and even all the design would be generated by me prompting the AI to tweak and generate the frontend code. This is an excellent way to see how AI handles CSS and general usability. For this, I use an AI Tool called Cursor. There are many tools available, but since subscribing to their pro plan, I’ve seen no real reason to change. Some of the key advantages I see over other AI tools are: Project-wide context awareness: Cursor understands the entire codebase rather than a single file, which leads to more accurate suggestions and safer large-scale changes across interconnected systems. Multi-file editing and refactoring: Cursor can apply consistent updates across multiple files in one action, making large refactors and cross-cutting changes significantly more efficient. AI-driven workflows: Cursor acts as an AI agent that can plan and execute multistep development tasks rather than just offering inline code suggestions. Context-aware integrated chat: Cursor’s built-in chat has full awareness of the repository and can directly modify code, reducing the need for manual context sharing. Model flexibility: Cursor allows developers to switch between different AI models, enabling optimisation for performance, cost, or task-specific quality. Optimised for complex systems: Cursor performs particularly well in large and complex codebases where deeper reasoning and architectural understanding are required. Faster end-to-end task completion: Cursor often reduces overall development time by completing entire tasks in fewer steps, even if individual code suggestions are not the fastest. AI-native IDE design: Cursor is built from the ground up as an AI-first environment, enabling tighter integration and more advanced workflows than plugin-based alternatives. FYI: I’m not sponsored by Cursor, I just love how clean and simple it is too pickup and use! There are many others on the web that you can choose instead! What FractalAI Actually Does Well, as you may have guessed already from the name of the blog post, the project I chose to build what a fractal generator 100% built on modern browser technology. The code is all open-source and free for anyone to clone, modify and use as they wish! The code repository is on GitHub here. The project currently comes with the following features: Fractal Selection: Choose from 100+ fractal types via dropdown menu. Adjustable Iterations: Control detail level (10 to 400 iterations). 35+ Colour Schemes: Classic, Fire, Ocean, Rainbow variants, Monochrome, Forest, Sunset, Purple, Cyan, Gold, Ice, Neon, Cosmic, Aurora, Coral, Autumn, Midnight, Emerald, Rose Gold, Electric, Vintage, Tropical, Galaxy, Lava, Arctic, Sakura, Volcanic, Mint, Sunrise, Steel, Prism, Mystic, Amber, and more. Zoom and Pan: Click and drag to pan, scroll or double-click to zoom. Real-time Parameter Adjustment: Adjust Julia set constants, scales, and other parameters in real-time. Screenshot Capture: Save current view as PNG with EXIF metadata. FPS Monitoring: Real-time frame rate display. Coordinate Display: View and copy exact fractal coordinates. Presets System: Quick-load pre-configured fractal views. Favourites System: Save and manage favourite fractal configurations. Share & URL State: Share fractals via URL with encoded state. This was just a copy / paste from the README.md file in the repository, it actually comes with more features that you can read about like Machine Learning-Powered Discovery, Performance Optimizations, and Advanced Features. I’ve been fascinated with Fractals for many years, I actually dabbled with fractal generation and Web Workers back in January 2010, scarily that’s a whole 16-years ago! That makes me feel very 👴! The reason fractals have always fascinated me is perfectly summed up in this quote from the godfather of fractals Benoît Mandelbrot: Bottomless wonders spring from simple rules, which are repeated without end. I find it simply astounding that a whole infinite world can be rendered by a computer using such "simple" mathematics. For example, the iconic Mandelbrot fractal is all generated via this neat little formula: zn+1​=zn2​+cWhere: z0​=0 c∈C (a complex number) A point c belongs to the Mandelbrot set if the sequence does not diverge: n→∞lim​∣zn​∣→∞Which generates this absolutely stunning image that you can literally zoom in to forever! The image above really doesn’t do the details of the Mandelbrot justice! So why not take a look around the original setting I used to render it here. Why the Browser Is Surprisingly Good at Rendering Fractals What makes the browser surprisingly good at rendering fractals is not just raw capability, but accessibility and reach. A fractal renderer written in the browser can run anywhere instantly with no installation, no setup, and no platform constraints. That is a powerful starting point. Additionaly there's JavaScript, as the language behind this project, it is embedded in every modern browser, making it one of the most widely available execution environments in the world. This accessibility isn't theoretical, it shows up in everyday life. Just last month, while walking my 8-year-old son home from primary school, he told me about what he had been learning in "Coding Club" using his BBC micro:bit. He was genuinely excited when he said: Daddy, I’ve been learning all about something called JavaScript! That moment really highlighted the scale of JavaScript’s reach. It is not just a professional tool, it is something being introduced at a very young age. When I explained that it forms a large part of my own work, it reinforced how deeply embedded it is in both education and industry. It is remarkable to think that a language first created by Brendan Eich in just 10-days has evolved into a platform capable of driving complex visualisations like fractal rendering! This, combined with modern browser features such as high-performance JavaScript engines, GPU acceleration through WebGL, and efficient canvas APIs, the browser has become an unexpectedly capable environment for this kind of computationally intensive work. AI as a Design Partner, Not Just a Tool I’ve found that AI is most powerful when it is treated as a design partner rather than just a tool for generating code. While it is clearly effective at implementation, its real strength lies in collaboration. You can ask it to plan a feature, and it will respond with probing questions that help clarify intent, constraints, and direction. Those questions are not incidental, they are often the key to shaping a better outcome, giving you the opportunity to guide and refine the approach early in a project. Because of the breadth of knowledge it draws on, the interaction feels less like issuing instructions and more like a back and forth design "conversation". It becomes a space for exploring ideas, testing assumptions, and iterating quickly before committing to an ideal solution. This became particularly valuable in areas where I would not usually feel confident, such as design. Rather than treating that as a limitation, I leaned into AI to generate an initial direction. From there, my role shifted to refinement. Once a baseline design was in place, I could make targeted adjustments to the CSS and HTML, either through follow-up prompts or by working directly with insights gathered from the Browser DevTools. Although I had originally set myself the constraint of not writing any code, I found that introducing precise, real-world context from DevTools significantly improved the quality of the AI’s output (more on this later in the post). By feeding in specific observations rather than abstract instructions, the responses became more relevant, more predictable, and easier to iterate on. This is where AI moves beyond being a simple tool. It becomes a collaborator that helps shape both the design thinking and the implementation, with you firmly guiding the direction at each step. The Architecture (Without Writing Code Myself) This was easily the most engaging part of the process and where the most learning happened. Having the freedom to choose technologies and then observe how AI composes them into a coherent architecture is genuinely insightful, especially when you examine the reasoning behind those decisions. When you are unsure whether a technology is a good fit for a project, you can quickly create a "throwaway" prototype and evaluate it. That shifts the role of AI from a coding assistant to a decision support tool. You are not just generating output, you are generating evidence. Over time, this creates a feedback loop where each experiment improves your judgement. You learn what works, what to avoid, and why. This aligns closely with core Agile principles: Fail early to surface risk Iterate quickly to explore options Learn continuously to improve decisions If you use AI for anything, use it to accelerate learning. Speed only matters when it leads to better decisions. Machine Learning Inside the Browser For many years, I’d wanted to have a play with machine learning (ML), but had never really had the opportunity. With the arrival of browser-based libraries like TensorFlow.js, the idea of running ML directly in the browser suddenly became far more accessible. In this project, I saw an interesting opportunity to apply it to the “random fractal position” feature. The goal was simple on the surface. A user selects a fractal and a colour scheme, presses a button, and gets a random set of x, y, and z values to explore. In practice, it was not that simple. When the AI first implemented this in plain JavaScript, I ran into a fundamental problem. A large proportion of fractal space is effectively empty, which meant most “random” positions resulted in a blank or uninteresting visual. I initially tried constraining the co-ordinates to specific regions, but with over 100 fractals available, this quickly became unmanageable. So I started thinking differently. Instead of hard-coding “good” regions, what if the system could learn what a good fractal view looks like? That is where machine learning came in. The idea was to train a lightweight model to recognise visually interesting outputs, effectively teaching the random feature to make better choices over time. Using AI to help generate and iterate on this approach made the experimentation surprisingly fast. Originally, I explored using TensorFlow.js, but it turned out to be far too heavy for this use case. Even compressed, it added around 450 KB of JavaScript, which is significant given the existing bundle size for all the fractal rendering functionality. Instead, I opted for Synaptic. It is much smaller at around 45 KB when Brotli compressed, and while it has not seen recent updates, it proved more than capable for what I needed. It also keeps the overall performance profile of the application in check, which was a key consideration. If required, this approach leaves the door open to swap Synaptic out for a more modern ML library in the future. But for now, it demonstrates something powerful. You can run meaningful machine learning directly in the browser, and use it to enhance user experience in ways that would be difficult to achieve with traditional logic alone. As mentioned in the README.md file in the GitHub repository it is used for the following functionality: ML-Based Scoring: Uses Synaptic.js neural network to score fractal configurations. Hybrid Algorithm: Combines fast heuristic screening with ML refinement. Personalised Learning: Trains on your favourites to learn your preferences. "Surprise Me" Feature: Discover interesting new fractal zooms automatically. Background Training: Model retrains automatically as you add favourites. Local Storage: ML model and favourites persist in browser storage. Note: You will find all of this functionality in the “Full-screen view” of the fractal viewer. The standard UI was starting to feel quite crowded, so that ended up being the most sensible place for it. What Worked Surprisingly Well Planning Planning from the AI was excellent. This may be a Cursor-specific feature, but the follow-up questions it asked about how I wanted to approach a problem were a really nice touch. When you are unsure which direction the AI will take, this gives you useful insights early on. Crafted Prompts Top AI Tip: Detailed prompts made a huge difference. The more specific I was, the more reliable the output became. In some cases, I even included images or copied exact code from DevTools just to make sure the AI fully understood the context. I really liked how Anthony Alicea explains AI in his Understanding AI-Assisted Software Development course. He visualises the AI “brain” as a cube, where each smaller blue cube represents a piece of knowledge the AI has learned. It’s a simple idea, but it makes how AI works much easier to grasp. A visualisation from his course is shown below: This “knowledge” could include things like: Documents Images External data Specifications Examples Instructions Your job when prompting the AI is to “sail” across this ocean of knowledge and get to a favourable output on the opposite side. To get better results, you need to give the AI waypoints or "lighthouses" to direct the AI through this ocean of knowledge, your prompts are these "lighthouses". That way, you guide the path it takes, rather than leaving it to figure one out on its own. So rather than saying: Build me a website that renders fractals Be more specific: Build me a website that renders fractals. Start with a simple Mandelbrot set and use the Canvas API to draw it. I’d also like you to leverage the WebGL API in the visualisation. Make sure the code is well documented and uses modern linting tools like Stylelint and ESLint to keep it maintainable. I also want to use Vite as the local development server… Now, you might be thinking, that’s a long prompt! It is. But if you want reliable output, you need to give the AI clear instructions to follow. Otherwise, it will just “set sail” across that vast corpus of information and produce something unfocused because it has too much freedom. If there’s one thing I learned from this project, it’s this: spend time crafting detailed prompts. That’s how you steer the ship across the ocean of information and actually reach the result you want. Rules Another useful thing I learned is that you can give the AI a consistent set of rules to follow across every prompt. In Cursor, these live in the .cursor/rules directory in your repository. In the FractalAI project, I ended up with four sets of rules: 00-core-standards.mdc 0-security-dependencies.mdc 20-performance-frontend.mdc 30-testing-quality.mdc The AI refers to these rules for every prompt, giving it consistent context and guidance. You might notice they look AI-generated, and you’d be right. It’s a bit meta, but I asked Cursor to write the rules it would follow in future prompts. The thinking behind this was simple: it is far more likely to understand its own instructions than my rambling ones! I’m not sure if this is specific to Cursor, or if other AIs follow a similar approach. It would be great to see some standardisation here to allow these rules to be portable across multiple AI tools in the future. If this is already happening, I’d love to know. Please feel free to let me know if you have any information! Writing Tests If you are not a fan of writing tests, even though you know you should, AI helps a lot. If it can write the code, it can just as easily write the tests too. Where a human developer might get tired of writing tests, AI doesn't. That makes it much easier to push towards high coverage and keep things consistent. And if you enjoy that warm feeling of seeing a wall of green in your terminal's test output, this definitely helps. I’m one of those people: Can you imagine how long it would take to get 100% coverage across all files, Statements, Branches, Functions, and Lines? That's a lot of work for one (obsessive) person. Or, a perfect job for AI! Refactoring Where AI really excelled in this project was its ability to understand even the smallest details of the codebase. That makes refactoring fast and almost effortless. For example, I initially used TensorFlow.js, but quickly realised it was too heavy and overly complex for what I needed. So I gave a simple prompt: Could you swap out TensorFlow.js and use Synaptic instead? The page weight is too high and it’s more complex than required. The important part here is the added context. By explaining why I wanted the change, I was building up the AI’s understanding of the project. From just that, it learns that I care about performance and keeping things lightweight, which leads to better, more aligned outputs as the project progresses. If you combine AI’s ability to refactor quickly with version control like Git, you are onto a winning combination. You can easily branch and tag different versions of your project to experiment with alternative approaches or technologies. If a refactor does not work out, just roll back to a previous commit, or simply scrap the branch / tag. One thing to watch out for if you do this is be wary of your node_modules folder. Projects tend to get a bit cranky if the dependencies are not installed correctly. And no, committing node_modules to your repo is not the answer! 🤮 An added bonus when using this technique is if you have good test coverage, you can swap out a technology and quickly run your tests to see what has broken. It makes validating a refactor fast and far less risky. This is where AI and testing really come into their own. The AI can make sweeping changes, and your tests act as the safety net to catch anything that breaks. Proof of concepts At the start of my career in digital marketing, there was a familiar pattern. Most of the budget went on UX and design. By the time it reached the build phase, you would give an estimate and hear, “We don’t have the budget for that.” Classic waterfall. Siloed teams. And not much left for actually building "the thing". There was even a running joke that what we really needed was a big “build it” button, so the technology part cost next to nothing. When it comes to proof of concepts, or spikes as some call them, you are essentially writing throwaway code. This is where that “build it” button starts to feel real. With a bit of direction on the tech stack and constraints, you can have AI produce a working prototype in hours, not days. Especially when dealing with unfamiliar technologies. What I’m really saying is that AI is great for testing ideas in code. Going back to the Agile principles I mentioned earlier in the post: Fail early to surface risk Iterate quickly to explore options Learn continuously to improve decisions These all map perfectly to AI-generated proof of concepts. Word of warning: be careful using AI-generated code in live / production without proper validation. Make sure it goes through the usual checks like Pull Requests (PRs), Continuous Integration, and Deployment (CI/CD) gates. If something goes wrong, you can't blame the AI (well you could try, but I doubt it would go down well!) Rule of thumb: There should always be a human reviewing AI-generated code, especially when sensitive data is involved. Making unfamiliar things make sense I’ve touched on this a little in the previous sections, one of AI’s superpowers is being able to explain complex concepts to the user when required. This is especially true when it comes to software development and technologies a user has no experience of before. AI is basically an incredibly patient coach who is always willing to answer your questions, no mater how small or stupid they may be! When combined with a Code UI like VS Code, you are really working with a powerful workflow. Being able to highlight individual rows and blocks of code and get AI to explain exactly what is happening is a dream come true for anyone learning to code in 2026! I know I wish I had it when I was a junior developer, far to many years ago! Another thing I found really useful is learning through optimisation. You might write a piece of code you understand, then ask the AI: Are there any edge cases I have missed? How can I make it more maintainable? Does this code follow best practices for the framework? How can I make it more performant? Will this code perform well if the website is scaled up? How can I make it easier to read for other developers? etc… These kinds of questions help reinforce your understanding, especially around the less obvious parts of a language. Building momentum Because I’m so old, I remember when Stack Overflow first appeared back in late 2008. It was a game changer for web development and beyond. A place to ask questions and get a range of answers, some useful, some less so. I’ve never been one to answer loads of questions, but I do try to go back and share solutions when I find them. The downside is it breaks your flow. One minute you are productive, the next you are stuck searching for answers, sometimes for hours. And if your problem is niche and not already answered, you could be left waiting days for a response. With AI built into your code editor, you have all that knowledge and reasoning right at your fingertips. No need to leave the comfort of your IDE or go searching the web for that one insight that leads to a “Eureka” moment. The result of using AI in this way is simple. Higher productivity, less frustration, and faster learning. A win all round. Where AI Fell Short (and Needed Steering) The definition of insanity To quote Albert Einstein: Insanity is doing the same thing over and over again and expecting different results. At times, this is precisely what using AI felt like. It would get stuck on a particular solution and keep coming back to it, no matter how I rephrased the prompt or tried to steer it in a different direction. It was incredibly frustrating. The only reliable fix was to start a new chat, which meant losing all the project context, or switch to a different model in Cursor. I usually run in “auto" mode to balance cost and capability, but switching models also meant losing that built up understanding within the AI prompt. There may be a better way to handle this without resetting everything? Again, if you know, please let me know. Over-engineering simple problems If there’s one thing you don’t need to ask AI to do, it’s over engineer a solution. I saw this a lot with CSS. Even simple requirements would take longer than expected and result in far more code than needed. Take something basic like centring a
across all screen sizes. This is a real example from the Benchmark panel in the top right of the FractalAI UI: AI output :root { --center-x: 50%; --center-y: 50%; --translate-center: translate(-50%, -50%); --container-display: flex; --container-align: center; --container-justify: center; } .container { position: relative; display: var(--container-display); align-items: var(--container-align); justify-content: var(--container-justify); height: 100vh; width: 100%; } .container::before { content: ""; position: absolute; inset: 0; background: radial-gradient(circle, rgba(0,0,0,0.05), transparent); pointer-events: none; } .centered { position: absolute; top: var(--center-y); left: var(--center-x); transform: var(--translate-center); display: grid; place-items: center; padding: calc(1rem + 2px); min-width: clamp(200px, 50%, 400px); aspect-ratio: 1 / 1; backdrop-filter: blur(8px); box-shadow: 0 2px 4px rgba(0,0,0,0.1), 0 8px 16px rgba(0,0,0,0.15); } @supports (height: 100dvh) { .container { height: 100dvh; } } @media (min-width: 768px) { .centered { transform: translate(calc(-50% + 2px), calc(-50% + 2px)); } } Why use one way to centre a
when you can use three! Flexbox (align-items + justify-content) Absolute positioning (top / left + transform) Grid (place-items) Additionally, it threw in CSS variables that were never reused and @supports queries where they were not needed. I’d expect a developer with a good grasp of modern CSS to write something more like this: Flexbox (modern) .container { display: flex; justify-content: center; align-items: center; height: 100vh; } Grid (modern) .container { display: grid; place-items: center; height: 100vh; } Look how much more maintainable and easier to debug the simpler approach is. This is definitely something to watch for when reviewing or generating AI code. Confidently wrong outputs The tricky thing with AI is how convincing it sounds. Even when it's wrong. I did not see this much in this project, but I ran into it when I worked for Government Digital Service (GDS). I asked Google Gemini for an overview of the changes from WCAG 2.1 to WCAG 2.2. The response sounded accurate, at least from my perspective as someone who understands the basics of accessibility without being an expert. But when I shared it with our dedicated accessibility team, it did not hold up. It was full of inaccuracies and false claims. It is a good reminder not to take AI output at face value, especially on topics you are not deeply familiar with. Inconsistent outputs between prompts It’s incredible how much difference a single word in a prompt can make to the output. In this example, I asked the AI to write a test for a hypothetical login function in another project I was working on. Prompt 1 “Write a test for a login function” AI Output: test('login works', () => { expect(login('user', 'pass')).toBe(true); }); Prompt 2 “Write a comprehensive test for a login function” describe('login', () => { it('should succeed with correct credentials', () => { expect(login('user', 'pass')).toBe(true); }); it('should fail with incorrect password', () => { expect(login('user', 'wrong')).toBe(false); }); it('should fail with empty input', () => { expect(login('', '')).toBe(false); }); }); Just by adding “comprehensive”, the output is vastly different. This might seem like a negative, and it is something to be aware of, but it links back to the Crafted Prompts section earlier. Prompt 1 lacks detail, and even Prompt 2 is still pretty vague. Neither is a great example of how to get the best from AI. It just reinforces the need to be clear and specific in what you are asking for. One extra word. An entirely different level of quality. Performance blind spots For every project, I work on, web performance is a priority. I see it as the foundation of accessibility, regardless of a user’s device, connection, or location. During this project, I noticed that unless you explicitly ask for it, performance is not a priority in AI-generated code. In some cases, fractals would not render at all. As an example, I asked Cursor to find unoptimised code in FractalAI. It highlighted this: For non line based fractals, ProgressiveRenderer increases iterations on each requestAnimationFrame tick and calls fractalModule.render again to recreate the draw command This is particularly problematic for the Mandelbrot, and other fractals that are shader based. Using regl, which already calls render() on every requestAnimationFrame, this approach creates a new draw command on every frame. That is extremely inefficient for both CPU and memory. After prompting, the issue was resolved. Thanks to my visual render tests using Playwright, I could confirm that none of the fractals broke during the optimisation. Another win for visual testing! This is also something I can automate. As mentioned earlier, Cursor’s Rules feature can enforce performance considerations. Writing a rule for this is now high-up on my to-do list. Forgetting earlier constraints This is a surprisingly human like trait of AI. As prompt "conversations" go on, it starts to forget earlier constraints. You often have to repeat yourself or restate key requirements it has quietly ignored. Over time, parts of the project context seem to drift, much like people forgetting details as time passes. Going back to the sailing analogy in Crafted Prompts, it is as if the AI loses sight of the waypoint “lighthouses” you set earlier. Elementary mistakes This happened more often than I expected. For such an advanced tool, the mistakes were surprisingly basic. I saw this when asking the AI to update outdated npm packages. A simple prompt like: Update all npm packages in this project, and run the coverage tests to ensure nothing has broken. In Cursor, you can see what it is “thinking”, and it frequently searched for package versions from 2023 or 2024. It was not using the latest information. It was only when I explicitly told it the current year was 2026 did it start checking for up-to-date versions! This is a good reminder to be explicit when you need current information, especially for dependencies, libraries, and documentation. Performance Challenges in the Browser When I first planned this side project, I was quite naïve. My initial idea was to build a 3D fractal renderer in the browser using Three.js. It didn’t take long to realise this wasn’t going to be viable. Even on an M1 MacBook, the frame rate was so low it was effectively unusable. This is where AI proved its value again. By quickly prototyping the idea, it became obvious in the first iteration that 3D rendering wasn’t going to work. That allowed me to pivot early to a 2D approach using an entirely different WebGL library. Without that early validation, I could easily have spent far longer trying to optimise something that fundamentally isn’t practical right now. There simply isn’t enough available CPU and GPU power in a typical machine to support real-time 3D fractal rendering in the browser at a usable level. The key thing to understand with fractals is the sheer volume of maths involved in rendering every single pixel. Each pixel can require hundreds, sometimes thousands, of iterations before you get a final value. So, even at a relatively modest resolution like a 1810px × 1131px image, that is over 2 million pixels to compute for that single image. Multiply that by hundreds or thousands of calculations per pixel, and you quickly see the scale of the problem. Without highly optimised code, the browser simply does not stand a chance of keeping up. It becomes less about the idea itself and more about the limits of the hardware running it. Looking ahead, it is hard not to think about how this changes with more advanced GPU capabilities. If compute continues to accelerate, especially with emerging technologies, calculations at this scale could eventually take milliseconds rather than seconds. As you zoom deeper into a fractal, the cost per pixel increases. To preserve detail and avoid visual artefacts, the renderer needs to run more iterations for each pixel. Beyond a certain point, the amount of computation required makes real-time interaction impractically slow. There is also a more fundamental limitation around numerical precision. In the browser, JavaScript numbers use IEEE-754 64-bit double precision, which eventually cannot represent the minuscule coordinate differences needed at very high magnifications. You can push beyond this using higher precision techniques, such as BigInt-based arithmetic or “double-double” approaches, but that additional precision comes at a significant cost. Each pixel becomes even more expensive to compute, which further impacts performance. What this all really comes down to is simple. Rendering fractals efficiently in the browser is hard. That being said, it is one of the quickest ways to max out your CPU and GPU, turn your computer into a heater, and simultaneously watch your electricity bill shoot up! What Building This Changed About My View of AI When I started this project, I was sceptical about how useful AI could be for coding. I had used tools like ChatGPT and Google Gemini for research, but never properly explored the coding side. The term “Vibe coding” didn’t help either. If anything, it put me off. After reading Vibe code is legacy code, the last thing I wanted was to introduce more legacy code into a project. I also find the term itself a bit meaningless, especially after it was named word of the year in 2025. It felt like just another buzzword that would disappear as quickly as it arrived. We have seen this pattern before. Technologies get hyped as “the future” and then quietly fade away. Anyone remember Google Glass from 2013?? Looking at the defintion of "vibe coding": Vibe coding is an intuitive, fast paced way of writing code where you prioritise momentum and experimentation over upfront planning, often with the help of tools I’m pleased to say this project has thoroughly changed my perspective on AI in software development. I still dislike the term “vibe coding”, but the practice behind it is undoubtedly what I found most useful and genuinely exciting while building FractalAI. As mentioned earlier, if you put strong guard rails in place such as mandatory human reviews for all production PRs, along with solid performance and security practices, AI becomes a genuinely valuable tool for learning and rapid prototyping. That said, I openly admit this is an idealistic view. In reality, there will always be individuals, companies, and bad actors willing to use AI in ways that harm the web for their own gain. When it comes to new and exciting technology on the web, it often follows the same familiar pattern. A few people find ways to exploit it for their own benefit, usually at the expense of everyone else. As Paula Poundstone put it best: This is why we can’t have nice things. The Future of AI-Generated Software If there’s one thing I know about AI-generated software, it’s that it’s not going anywhere. The AI sector is projected to reach $539 billion in 2026, up from $391 billion in 2025. That’s a 38% increase in a single year. Longer term, it’s expected to push into the multi-trillion dollar range. Spending alone tells the story. Global AI investment hit around $1.5 trillion in 2025, and it’s still accelerating. This isn’t hype, it’s momentum. At the same time, the risks are evolving just as quickly. A new Claude model, Mythos, from Anthropic has reportedly identified a significant number of vulnerabilities across major operating systems. Some experts believe it shows an unprecedented ability to detect and potentially exploit security weaknesses. Whether that is true or is just scaremongering remains to be seen, but it’s telling that major financial institutions have already been given early access to the model ahead of any public release. As I’ve said, AI is here to stay. The genie is well and truly out of the bottle. My plan is simple. Learn as much as possible over the coming months and years. This is happening whether we like it or not, so it makes far more sense to understand its potential and how it’s being used than to ignore it. If you don’t, you can be sure others in your respective field will be. As this goes far beyond software development. AI’s reach is expanding fast, so buckle up. Summary This project was an experiment in what happens when you step back from writing code and let AI take the lead. FractalAI is a fully AI-generated fractal renderer running in the browser, built to explore both the potential and the limits of modern AI-assisted development. Along the way, it became clear that AI is far more than just a code generator. When used properly, it acts as a design partner, a rapid prototyping tool, and a way to accelerate learning. But it's not perfect. It needs direction, context, and clear constraints to produce reliable results. The browser turned out to be a surprisingly powerful platform for this kind of work, capable of rendering complex, infinite visuals while even supporting lightweight machine learning. Combined with AI, it creates a feedback loop where ideas can be tested, refined, and understood far faster than traditional approaches. The biggest takeaway is simple. AI is not replacing developers, it is changing how we work. If you use it well, it becomes a tool for thinking, learning, and making better decisions, not just writing code faster. Well, there we go, yet another blog post where I’ve somehow written far more than intended! If you’ve made it this far, I’m genuinely impressed. Please accept this virtual gold star as a reward: ⭐ As always, feedback and comments are very welcome. You can contact me here. FractalAI project Below, you can find the links to the repository and the live site: FractalAI on GitHub Live site I’m completely open and actively encourage raising PR's and sending me feedback / issues about the project (with, or without AI!). Post changelog: 21/04/26: Initial post published. --- End: FractalAI: Generating Infinity in the Browser --- Start: Using Cloudflare Workers and reCAPTCHA v3 for a Static Site Contact Form Published on: 09 March 2026 https://nooshu.com/blog/2026/03/09/using-cloudflare-workers-and-recaptcha-v3-for-a-static-site-contact-form/ Main Content: Introduction I recently wrote a blog post that involved using a Cloudflare Worker to serve Brotli 11 compressed HTML rather than the standard uncompressed HTML file. Off the back of this post, I’ve decided to write one about my Cloudflare Worker setup that I use for my Contact Page. In order to stop the spam, I also integrated reCAPTCHA v3 into the form functionality. The form is pretty simple, it asks a visitor for their full name, email address and the message they with to send to me. The Worker then pulls together this information and sends an email to a custom email address that points to my personal email address. This allows me to easily filter messages that come from the site, which I then have the option to reply to if I so wish. It’s worth noting that this post was heavily inspired by Sia Karamalegos’s post from 2024 about migrating from Netlify to Cloudflare. I had the absolute please of meeting Sia in Amsterdam when I spoke at Performance.now() 2023, she was MC for my talk and I really appreciated her support when I was (quite frankly) freaking out with nerves before the talk! So why use a serverless endpoint for the contact form? Well, as I’ve mentioned many times before, this blog is a static site built using 11ty and hosted on the Pro plan of Cloudflare Pages. There simply is no backend to point the POST method too, which is required to send the email. Previous solutions I’ve used This isn’t the first time I’ve changed the way the contact form works on this blog. I’ve counted 5 alternatives that I have used over the years! Isn’t Git great for tracking history! I certainly wouldn’t be able to remember them all with my terrible memory! So, if you aren’t on Cloudflare, or you would rather not use a Worker for this, then there’s a list of perfectly viable alternatives below: Netlify Forms URL: Netlify Forms Cost: Free+ (depending on the Netlify tier you choose) Netlify Forms is a built-in form handling service for sites hosted on Netlify. It allows you to collect and manage form submissions from static websites without building or maintaining a backend. Submissions can be viewed in the Netlify dashboard or forwarded via email or webhook. I still think it’s a real shame that Cloudflare doesn't offer a form setup that is as easy to integrate and use as Netlify Forms, although, if it did, I wouldn’t need to be writing this blog post! Cloudflare Pages Functions + MailChannels URL: https://developers.cloudflare.com/pages/functions/ & https://www.mailchannels.com/ Cost: Free plan available ($10+ depending on volume-based pricing) When I initially migrated from Netlify to Cloudflare for hosting I used this setup, that was until MailChannels sunset its free email sending service for Cloudflare Workers users (me), at that point I started looking for other solutions. As you will see below. Formspree.io URL: https://formspree.io/ Cost: Free tier available ($15+ a month for paid plans) Formspree is a hosted form service that lets you collect form submissions without building your own backend server. You simply point your form at Formspree, and it handles sending submissions to your email address or other API endpoints, it comes with built-in spam protection and integrations for various workflows. Formspark.io (previously Submit Form) URL: https://formspark.io/ Cost: Free tier available ($9+ a month for paid plans) Formspark is a basic hosted form backend that lets you accept and manage form submissions without the need for your own backend server. You simply submit forms to Formspark, and it stores (or forwards) the data, with options for email notifications, webhooks, and spam filtering. Botpoison URL: https://botpoison.com/ Cost: Free plan available ($4+ per month depending on bot verifications required) Botpoison is an invisible anti-spam and bot prevention service for web forms that blocks automated submissions without requiring CAPTCHAs or extra steps from users. It works by analysing form interactions and applying proof-of-work challenges and reputation checks to distinguish human traffic from bots. I started using it when I was still receiving contact form spam from my Formspark.io setup. Resend URL: https://resend.com/ Cost: Free plan available ($20+ for paid plans) Resend is a developer-focused email delivery service, providing a simple API and Simple Mail Transfer Protocol (SMTP) interface for sending transactional and broadcast emails from your applications or website. It handles features like deliverability, bounce tracking, suppression lists, and email analytics, so a developer doesn't need to manage email infrastructure themselves and can be integrated with many platforms. Resend is currently part of my email workflow, as I will describe later in the post. Overview of the setup My current setup is: Cloudflare Pages Functions (the Worker) + Google reCAPTCHA v3 + Resend for email. A high-level view of this workflow is: User populates the contact form with their details and message reCAPTCHA v3 runs and adds a short-lived verification token The form POSTs to /api/contact (via the Pages Function, that sits in functions/api/contact.js in the root of my repository The Pages Function, then: verifies the token with Google validates the user inputs sends the validated inputs via email using Resend Redirects the user to the “Thank You” page, confirming that the message has been sent. Google reCAPTCHA v3 I have to say, out of all the things written in this post, researching about how exactly Google reCAPTCHA v3 works, was probably the most fascinating! I really love the fact that it is all invisible to the user, and it is based on a complex (but logical) scoring mechanism. There’s no need for a really frustrating visual CAPTCHA’s that don’t prove you are human, “they prove that you are American!” (I can’t take credit for this quote, that I believe goes to Terence Eden, or at least that's who I remember reading it from!) So how exactly does reCAPTCHA v3 decide if you are a human? Well, it turns out it analyses a user's behavioural and contextual signals rather than setting a user a visual challenge, and based on those signals it creates a risk score. The biggest signal comes from how the user behaves on the page. Behavioural analysis of a user's session reCAPTCHA observes a number of patterns and metrics from a user's interaction with the page, these include: Mouse movement patterns Typing speed and rhythm Scroll behaviour Click timing and placement Focus changes between fields or tabs Us humans are a little messy when using a computer. Our mouse movements curve and wander slightly, we pause without thinking, and our typing comes with the occasional hesitation or uneven rhythm. Skynet, on the other hand, tends to be a little too perfect. Its mouse paths are often straight, timing is predictable, and its interactions can happen far faster than any real person would manage. Those overly neat patterns are often a strong indicator that automation is at work. Browser and device fingerprinting Now, when I think of Browser fingerprinting I often associate it with advertising and companies tracking users across the internet. In all fairness, this is Google we are talking about, so it's very likely that they actually do this! :sad_face:. Browser and device fingerprinting is the way the reCAPTCHA script also examines the environment and device information of where the request is coming from. Typical signals include: Browser version and capabilities Installed fonts and plugins Screen resolution and device properties WebGL and Canvas fingerprint signals Cookie availability JavaScript execution behaviour Timezone of client Language settings You’d be amazed at how accurate these fingerprints can be. Each piece of information adds a little more entropy, meaning it becomes easier to distinguish one device from another. They are so accurate in fact I know many financial sectors or government departments use them for fraud detection. You really don’t need to look very far to find libraries like Fingerprint that do this all for you! Look at all the information it captured about you just by clicking this link! And you thought cookie tracking was bad! Note: There are many other fingerprinting libraries available, I’m not specifically targeting Fingerprint. So while I disagree with browser and device fingerprinting from a privacy and tracking perspective, it does have its uses when it comes to proving that a “user” is a human and not a bot. In particular, bots often run in headless browsers or minimal environments, which look very different from real user devices. Network and reputation signals Due to Google’s global network, it can also delve into global reputation data. It captures: IP reputation and history Known bot networks or proxy usage Data centre IP ranges Previous abuse patterns linked to the IP or session So much traffic flows through Googles network globally, it can detect suspicious network traffic surprisingly easily. Action Context Google can also score the predicted actions a user is going to make. With reCAPTCHA v3, developers call the library with an action name such as: login checkout signup comment From its countless trillions of observations over the years, Google already knows the typical login behaviour for a human (it takes approximately 3–10 seconds). Whereas, a bot can submit hundreds of logins per minute, which is a huge red flag for the use of automation. Scoring using Machine learning So what does Google do with all these various signals it captures? It feeds them into a machine learning algorithm for scoring, which will decide if whatever is interacting is a human or a bot. Score Meaning 0.9 to 1.0 Very likely human 0.5 Uncertain 0.0 to 0.3 Very likely bot At this point, it is then up to your backend to decide what to do with the score it gets back. For example: 0.8+ allow request 0.4–0.8 require MFA <0.4 block or challenge In my case, the Cloudflare Worker is my simple serverless backend, and it contains the following code to decide if the form submit is from a human or a bot: // Verify score (reCAPTCHA v3 returns scores between 0.0 and 1.0) // Lower scores indicate bot-like behavior. 0.5 is a common threshold, but 0.3 allows more legitimate traffic if (typeof verifyJson.score === "number" && verifyJson.score < 0.3) { return new Response( JSON.stringify({ error: "CAPTCHA verification failed", details: `Score too low: ${verifyJson.score}`, }), { status: 400, headers: { "content-type": "application/json" } }, ); } The code above is saying to reject any score that returns from Google that is under 0.3 (Very likely bot), You could of course easily change this threshold if you found it was being too aggressive with the filtering. Technical setup To use reCAPTCHA v3, you must first register your site in the Google reCAPTCHA admin console to obtain two keys. The “site key” is used on the client-side and allows the browser to request a reCAPTCHA verification token for when a user performs an action. The “secret key” must remain server-side and is used to verify that token with Google. This means storing the secret securely as an environment variable, for my setup this is done via the Cloudflare Pages build dashboard. The Cloudflare Worker then reads it from the environment variables during the build process. Once all configured, you can also use the Google reCAPTCHA admin console to: restrict the key to specific hostnames configure score thresholds to control how strict the bot detection should be The contact form (HTML and client-side) The HTML used for the form is all pretty standard, just remember to make it as accessible as possible! Semantic markup is the key to achieving this! If you are unsure how to do this, there’s an excellent post called Creating Accessible Forms on the WebAIM.org website. The HTML for my contact form looks like this:
Contact form
A version with fewer comments is available in this gist on GitHub. The contact form I use above has the following features: Honeypot and reCAPTCHA to block spam I’ve already detailed how Google reCAPTCHA works, so I won’t cover it again here, but a Honeypot is a simple first layer of protection. The basic theory behind a Honeypot is a simple form field hidden from real users but still present in the page markup. Bots that scan the source code often fill in every field they find, including this hidden one. If that field is completed, it is a strong signal that the submission came from a bot, not a human because it isn’t seen in the User Interface (UI), thus allowing the request to be rejected immediately. Client and server validation to ensure safe input Form validation is handled in two layers: On the client side, the form uses novalidate with custom JavaScript to check required fields and validate the email format for the name, email, and message inputs. On the server-side, the API performs its own validation by checking that required fields are present, enforcing max length limits for each field (name: 200 characters, email: 320 characters, message: 5000 characters), and confirming that a valid reCAPTCHA token is included with the submission. Accessible error handling and focus management Accessibility is built into the form by clearly associating it with the page title using aria-labelledby, providing an error summary and status regions with role=“alert" plus role=“status", also marking individual fields with aria-invalid and aria-describedby so errors are properly announced. Inline error messages use matching IDs for assistive technologies, required fields are clearly indicated with a *, and a visually hidden legend describes the
. When errors occur, the browser focus moves to the summary using tabindex="-1” so it can be announced immediately by the screen reader. UX updates to keep the user informed during submission The form includes the autocomplete attribute for the name and email fields, allowing users browser the option to suggest information already stored in the browser and pre-fill it for them. Additionally, there's a simple placeholder for the email input, letting a user know what input format is expected. When the form is submitted, the submit button is temporarily disabled and its label changes to “Sending…” while a status message informs the user that their message is being sent. After the submission is successful, the user is then redirected to the /contact/thanks/ page, as seen in the _redirect input field, the API then returns a 303 redirect to complete the process. Secure server side processing The form submits a POST request to /api/contact, handled by a Cloudflare function. Hidden fields such as _redirect, _append, and _email.subject control what happens on submit. On the server, the submission is sent as an email in both HTML and plain text formats before redirecting the user on success. The endpoint only accepts form-encoded or multipart content types. It also verifies the reCAPTCHA token using the CF-Connecting-IP header when available, and validates required fields and length limits, it then escapes HTML in the email body to ensure the content is safe. I’d like to think this form is pretty accessible and simple to use, but if you disagree, please do let me know. How very meta! It’s always fun to learn something new! Simplified CSP The great thing about not having to use a third-party form service for sending contact form emails (besides the slight reduction in cost), is that it simplifies my Content-Security-Policy. This is because I’m no longer having to establish a connection to an additional third-party. The Cloudflare Worker runs on my instance of Cloudflare pages, so it is embedded in the build. And thankfully there’s no client-side interaction when using Resend, as this all happens via the Cloudflare Worker, no modifications to the CSP required! This may not seem like a big thing, but the less you can rely on a third parties the better! There are numerous examples from the history of the web, where the failure of a third party has had a massive impact on the web: Fastly outage on 8 June 2021 Dyn DNS outage (2016) Facebook BGP outage (2021) Wikipedia Category on Internet outages An interesting point about the Wikipedia link above is you can see how prominent these outages have become over the decades, and also the fact that the placeholder category pages for the 2030s, 2040s, 2050s, and 2060s are already listed! Oh well, those outages are for my grandkids to worry about! 👴🏼 The Cloudflare Worker (Pages Function) So let’s get onto the final piece of the puzzle: the Cloudflare Worker code. This file sits in the root of my blog under functions/api/contact.js, this translates to the action="/api/contact” in the form code above. // Import a small helper that escapes HTML characters. // This prevents user-supplied content (like the message body) // from breaking HTML or injecting markup when we render it in the email. import { escapeHtml } from "../../_helpers/escape-html.js"; // Cloudflare Pages Functions export HTTP handlers as named exports. // `onRequestPost` will be invoked for POST requests to this function's route. // // Signature: // - `request`: the incoming HTTP Request object // - `env`: environment bindings (secrets, KV, etc.), configured in Cloudflare export const onRequestPost = async ({ request, env }) => { try { // 1. Accept only standard HTML form submissions // ------------------------------------------------ // We support: // - application/x-www-form-urlencoded (classic HTML
) // - multipart/form-data (forms that include files) // // Anything else (JSON, etc.) is rejected with 415 Unsupported Media Type. const contentType = request.headers.get("content-type") || ""; if ( !contentType.includes("application/x-www-form-urlencoded") && !contentType.includes("multipart/form-data") ) { return new Response( JSON.stringify({ error: "Unsupported content type" }), { status: 415, headers: { "content-type": "application/json" }, }, ); } // Parse the incoming form body. Cloudflare's Request object // exposes a `.formData()` helper which returns a FormData instance. const form = await request.formData(); // 2. Honeypot anti-spam field // ------------------------------------------------ // A "honeypot" is an extra hidden field that humans never fill in, // but bots often do. If this field is present and non-empty, // we treat it as spam and quietly redirect away. if (form.get("_its-a-trap!")) { // Redirect back to the homepage. We build the URL from the current request // so it works correctly across environments (preview, production, etc.). const hpUrl = new URL("/", request.url); return Response.redirect(hpUrl.toString(), 303); } // 3. Extract and normalize form fields // ------------------------------------------------ // We pull out the core fields we care about: // - name // - email // - message // - _redirect (optional; where to send the user after success) // // Every value is coerced to string and trimmed to avoid // issues with null/undefined and accidental leading/trailing spaces. const name = (form.get("name") || "").toString().trim(); const email = (form.get("email") || "").toString().trim(); const message = (form.get("message") || "").toString().trim(); const redirectUrl = ( form.get("_redirect") || "/contact/thanks/" ).toString(); // Basic required-field validation. If any are empty, // respond with a 400 Bad Request and a JSON error. if (!name || !email || !message) { return new Response( JSON.stringify({ error: "Missing required fields" }), { status: 400, headers: { "content-type": "application/json" }, }, ); } // 4. Length limits (simple validation / abuse mitigation) // ------------------------------------------------ // These caps are deliberately generous but help avoid: // - unrealistic payloads // - abuse where the form is used as a data pipe if (name.length > 200 || email.length > 320 || message.length > 5000) { return new Response(JSON.stringify({ error: "Invalid field lengths" }), { status: 400, headers: { "content-type": "application/json" }, }); } // 5. CAPTCHA verification (reCAPTCHA v3 or hCaptcha-compatible) // ------------------------------------------------ // The form is expected to include a CAPTCHA token: // - reCAPTCHA v3: `g-recaptcha-response` // - (optionally) hCaptcha-style: `h-captcha-response` // // If no token is present, we immediately reject the submission. const recaptchaToken = form.get("g-recaptcha-response") || form.get("h-captcha-response"); if (!recaptchaToken) { return new Response( JSON.stringify({ error: "reCAPTCHA token is required" }), { status: 400, headers: { "content-type": "application/json" }, }, ); } // Select the secret key from Cloudflare environment variables. // This must be configured in the project/environment: // - RECAPTCHA_SECRET_KEY or // - RECAPTCHA_SECRET const secret = env.RECAPTCHA_SECRET_KEY || env.RECAPTCHA_SECRET; if (!secret) { // If the secret is missing, that's a server misconfiguration, // so we return a 500 Internal Server Error. return new Response( JSON.stringify({ error: "Server not configured for CAPTCHA (missing RECAPTCHA_SECRET_KEY/RECAPTCHA_SECRET)", }), { status: 500, headers: { "content-type": "application/json" } }, ); } // For better fraud detection, we also send the user's IP to reCAPTCHA. // Cloudflare exposes client IP via: // - CF-Connecting-IP header // - request.cf.clientAddress const remoteIp = request.headers.get("CF-Connecting-IP") || request.cf?.clientAddress || null; // Build the request body for the reCAPTCHA verification endpoint. // It expects application/x-www-form-urlencoded payload. const verifyParams = new URLSearchParams({ secret, response: recaptchaToken.toString(), }); if (remoteIp) { verifyParams.append("remoteip", remoteIp); } // POST the verification request to Google's reCAPTCHA API. const verifyResp = await fetch( "https://www.google.com/recaptcha/api/siteverify", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: verifyParams, }, ); // If Google's endpoint itself is failing (network issue or 5xx), // treat this as a bad gateway (502) and surface a generic error. if (!verifyResp.ok) { return new Response( JSON.stringify({ error: "Failed to verify reCAPTCHA" }), { status: 502, headers: { "content-type": "application/json" }, }, ); } // Parse the JSON response from reCAPTCHA. // Example structure: // { // success: true/false, // score: 0.0-1.0, // hostname: "example.com", // "error-codes": [...] // } const verifyJson = await verifyResp.json(); // 5a. Hard failure: verification not successful at all // ------------------------------------------------ // We return a 400 with details about why it failed where possible. if (!verifyJson.success) { const errorCodes = verifyJson["error-codes"] || []; return new Response( JSON.stringify({ error: "CAPTCHA verification failed", details: errorCodes.length > 0 ? errorCodes.join(", ") : "Unknown error", }), { status: 400, headers: { "content-type": "application/json" } }, ); } // 5b. Soft failure: low reCAPTCHA v3 score // ------------------------------------------------ // reCAPTCHA v3 uses scores instead of explicit "I'm not a robot" prompts. // Lower values are more suspicious. Here we reject anything below 0.3. // This threshold is a trade-off between blocking bots and not annoying users. if (typeof verifyJson.score === "number" && verifyJson.score < 0.3) { return new Response( JSON.stringify({ error: "CAPTCHA verification failed", details: `Score too low: ${verifyJson.score}`, }), { status: 400, headers: { "content-type": "application/json" } }, ); } // 5c. Optional hostname verification // ------------------------------------------------ // The reCAPTCHA response includes a `hostname` field. // We check that the hostname reCAPTCHA saw matches the hostname of our request. // This helps prevent token reuse on other domains. const requestHost = new URL(request.url).hostname; if ( verifyJson.hostname && verifyJson.hostname !== requestHost && !requestHost.endsWith(`.${verifyJson.hostname}`) ) { // We allow subdomains (e.g. www.example.com vs example.com), // but log any unexpected mismatch to server logs for later inspection. // This log will show up in Cloudflare function logs. console.warn( `reCAPTCHA hostname mismatch: expected ${requestHost}, got ${verifyJson.hostname}`, ); } // 6. Prepare email content for Resend // ------------------------------------------------ // At this point, the submission has passed: // - basic validation // - anti-spam honeypot // - CAPTCHA checks // // Now we build an email to send to the site owner via Resend. // Fixed "from" address used with Resend. // This should be a verified sender domain for your Resend account. const fromEmail = "contact@example.com"; // Allow the subject to be overridden via a hidden form field `_email.subject`, // otherwise fall back to a sensible default. const subject = form.get("_email.subject")?.toString() || "New Message from Nooshu.com"; // Plain-text body, which is useful for mail clients that don't render HTML, note the use of JavaScript Template literals. const textBody = `New contact form submission on nooshu.com Name: ${name} Email: ${email} Message: ${message}`; // HTML body with simple markup for better readability. // We escape all user-supplied fields to avoid injecting HTML or scripts. const htmlBody = `

New contact form submission on nooshu.com

Name: ${escapeHtml( name, )}
Email: ${escapeHtml(email)}

Message:

${escapeHtml(
			message,
		)}
`; // Pull the Resend API key from Cloudflare environment variables. // This must be configured as a secret in the Pages project. const apiKey = env.RESEND_API_KEY; if (!apiKey) { return new Response( JSON.stringify({ error: "Server not configured for email (missing RESEND_API_KEY)", }), { status: 500, headers: { "content-type": "application/json" } }, ); } // 7. Call Resend's email API // ------------------------------------------------ // We issue a POST request to Resend's /emails endpoint with: // - from: sender (must be a verified domain) // - to: recipient(s) (in this case, the site inbox) // - reply_to: the visitor's email address, so "Reply" in your mail client goes to them // - subject, text, html: the message content const resendResp = await fetch("https://api.resend.com/emails", { method: "POST", headers: { "content-type": "application/json", Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ from: `Nooshu Contact <${fromEmail}>`, to: ["email@example.com"], reply_to: email, subject, text: textBody, html: htmlBody, }), }); // If Resend returns a non-2xx status, we surface a 502 to the client // and include the raw error body for easier debugging. if (!resendResp.ok) { const text = await resendResp.text(); return new Response( JSON.stringify({ error: "Email send failed", details: text }), { status: 502, headers: { "content-type": "application/json" }, }, ); } // 8. Final redirect on success // ------------------------------------------------ // If everything above succeeds, we redirect the user to a "thank you" // page (configurable via the `_redirect` field). // // We resolve `redirectUrl` relative to the current request URL // to avoid hard-coding the origin. const finalRedirect = new URL(redirectUrl, request.url); // 303 See Other is the canonical way to redirect after a POST, // telling the browser to perform a GET to the new URL. return Response.redirect(finalRedirect.toString(), 303); } catch (err) { // 9. Global error handler // ------------------------------------------------ // If anything unexpected blows up (network issues, runtime errors, etc.), // we catch it here and return a generic 500 JSON response. // // The error is stringified so it's at least inspectable in logs / responses, // but in a real-world scenario you might want to avoid leaking details // to the client and instead only log them server-side. return new Response( JSON.stringify({ error: "Server error", details: String(err) }), { status: 500, headers: { "content-type": "application/json" }, }, ); } }; A version with fewer comments is available in this gist on GitHub. Using Cloudflare Turnstile After I posted the blog post on Bluesky, Mastodon, and LinkedIn, I had a couple of replies back asking me why I hadn't used Cloudflare's CAPTCHA integration rather than Google's ReCAPTCHA v3. In all honesty I didn't realise Cloudflare had a competing product! So, thanks to Ryan Townsend and Paweł Grzybek for letting me know! I'm not going to be rewriting the whole blog post, but what I will do is share the client-side JavaScript code (turnstile.js), and the contact.js file for the API that I am using to integrate with Cloudflare Turnstile. Also, any additional changes I had to make. First the Code: // Lightweight, dependency-free integration for Cloudflare Turnstile. // This script: // - Validates the contact form on the client (for UX only – server still re-validates). // - Requests an invisible Turnstile token just before submit. // - Injects the token into a hidden field for the server to verify. // - Tries hard not to block the user if Turnstile is slow to load. (function () { // Public Turnstile site key for this widget. This is *not* a secret. // The matching secret key lives on the server and is used during // server-side verification in the Cloudflare Pages Function. const SITE_KEY = "0x4AAAAAACpYvR2v9J0i1tr_"; // The contact form we progressively enhance. If it does not exist // (for example, on other pages), bail out early. const form = document.getElementById("fs-frm"); if (!form) return; // --------------------------------------------------------------------------- // Hidden token field management // --------------------------------------------------------------------------- // Turnstile posts its token back to the server in a field that we choose. // The backend expects this exact name when validating the submission. const tokenInputName = "cf-turnstile-response"; let tokenInput = form.querySelector(`input[name="${tokenInputName}"]`); if (!tokenInput) { // If the hidden field was not rendered server-side, create it here. tokenInput = document.createElement("input"); tokenInput.type = "hidden"; tokenInput.name = tokenInputName; form.appendChild(tokenInput); } // --------------------------------------------------------------------------- // Accessible error + status UI helpers // --------------------------------------------------------------------------- const errorSummary = document.getElementById("form-error-summary"); const formStatus = document.getElementById("form-status"); // Show a high-level error message above the form and move focus there so // screen-reader users (and keyboard users) are notified. function showFormError(message) { if (errorSummary) { errorSummary.textContent = message; errorSummary.hidden = false; errorSummary.focus(); } } // Hide and clear the error summary. function clearFormError() { if (errorSummary) { errorSummary.textContent = ""; errorSummary.hidden = true; } } // Attach error state to a single field (ARIA attributes + inline message). function showFieldError(fieldId, message) { const field = document.getElementById(fieldId); const errorEl = document.getElementById(fieldId + "-error"); if (field) { field.setAttribute("aria-invalid", "true"); field.setAttribute("aria-describedby", fieldId + "-error"); } if (errorEl) { errorEl.textContent = message; errorEl.hidden = false; } } // Clear error state from a single field. function clearFieldError(fieldId) { const field = document.getElementById(fieldId); const errorEl = document.getElementById(fieldId + "-error"); if (field) { field.removeAttribute("aria-invalid"); field.removeAttribute("aria-describedby"); } if (errorEl) { errorEl.textContent = ""; errorEl.hidden = true; } } // Clear all per-field and summary errors. function clearAllErrors() { clearFormError(); ["name", "email", "message"].forEach(clearFieldError); } // Set a small, live-updating status message (polite ARIA live region). function setStatus(message) { if (formStatus) { formStatus.textContent = message; } } // --------------------------------------------------------------------------- // Client-side validation // --------------------------------------------------------------------------- // This mirrors the server’s validation rules, but is *only* for UX. // The server still validates everything again. function validateForm() { clearAllErrors(); const errors = []; const nameField = form.querySelector("#name"); const emailField = form.querySelector("#email"); const messageField = form.querySelector("#message"); if (!nameField.value.trim()) { showFieldError("name", "Please enter your full name."); errors.push(nameField); } if (!emailField.value.trim()) { showFieldError("email", "Please enter your email address."); errors.push(emailField); } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailField.value.trim())) { showFieldError("email", "Please enter a valid email address."); errors.push(emailField); } if (!messageField.value.trim()) { showFieldError("message", "Please enter a message."); errors.push(messageField); } if (errors.length > 0) { const summary = errors.length === 1 ? "There is 1 error in the form." : "There are " + errors.length + " errors in the form."; showFormError(summary); errors[0].focus(); return false; } return true; } // --------------------------------------------------------------------------- // Turnstile widget lifecycle // --------------------------------------------------------------------------- // `widgetId` is the handle returned by turnstile.render. // `pendingSubmit` tracks whether we should submit once a token arrives. // `submitButton` lets us disable / re-enable the call-to-action button. let widgetId = null; let pendingSubmit = false; let submitButton = null; // Called by Turnstile when a token is successfully generated. function onTokenReceived(token) { tokenInput.value = token; // If the user was waiting for the challenge to finish, we can now submit. if (pendingSubmit) { pendingSubmit = false; form.submit(); } } // Called when Turnstile hits an error or we decide the widget is unavailable. function onTokenError(errorCode) { tokenInput.value = ""; pendingSubmit = false; setStatus(""); console.error("Turnstile error:", errorCode); showFormError( "Verification failed. Please try again. If you use an ad blocker, you may need to allow Cloudflare on this site." ); if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Send Message"; } // If we have a widget instance, reset it so the user can try again. if (widgetId !== null && typeof turnstile !== "undefined") { turnstile.reset(widgetId); } } // Render an invisible Turnstile widget into the placeholder div. // The widget itself is not visible – it will run when we call execute(). function renderTurnstile() { const container = document.getElementById("turnstile-container"); // Only render once, and only on the contact page where the container exists. if (!container || widgetId !== null) return; if (typeof turnstile !== "undefined") { widgetId = turnstile.render(container, { sitekey: SITE_KEY, // Invisible mode: no explicit checkbox; Cloudflare decides when // to prompt for a challenge, if at all. size: "invisible", // We want full control, so we manually call turnstile.execute(widgetId). execution: "execute", callback: onTokenReceived, "error-callback": onTokenError, "expired-callback": function () { // Expired tokens are treated as missing; the server will reject them. tokenInput.value = ""; }, }); } } // Wait until the Turnstile script has loaded and the global `turnstile` // object is available. We poll for a short period so that a slow network // does not permanently block the user. function waitForTurnstile() { return new Promise((resolve) => { if (typeof turnstile !== "undefined") { resolve(); return; } let attempts = 0; const checkInterval = setInterval(() => { attempts++; if (typeof turnstile !== "undefined") { clearInterval(checkInterval); resolve(); } else if (attempts > 100) { // Give up after ~10s. At this point we will try to submit // without a token and let the server return a clear error. clearInterval(checkInterval); resolve(); } }, 100); }); } // As soon as the page is ready and Turnstile is (hopefully) loaded, // render the widget so it is ready by the time the user hits submit. waitForTurnstile().then(() => { renderTurnstile(); }); // --------------------------------------------------------------------------- // Submit flow // --------------------------------------------------------------------------- form.addEventListener("submit", async function (e) { // We always take over submission so we can validate + get a token first. e.preventDefault(); if (!validateForm()) return; submitButton = form.querySelector('button[type="submit"]'); if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Sending…"; } setStatus("Verifying, please wait."); // Make sure the Turnstile library has had a chance to load. await waitForTurnstile(); // If we still do not have a widget, try to render one and give the // browser a short moment to paint it. if (widgetId === null) { renderTurnstile(); await new Promise((resolve) => setTimeout(resolve, 100)); } // If for some reason we already have a valid token (for example, // Turnstile auto-ran earlier), we can just submit the form. if (tokenInput.value) { form.submit(); return; } // Normal path: ask Turnstile to run in invisible mode. When it finishes, // it will call onTokenReceived, which will submit the form if // `pendingSubmit` is true. if (widgetId !== null && typeof turnstile !== "undefined") { pendingSubmit = true; turnstile.execute(widgetId); } else { // We could not talk to Turnstile at all – surface a clear error. console.error("Turnstile widget not available"); onTokenError("widget-not-available"); } }); })(); A version with fewer comments is available in this gist on GitHub. Now the updates to the contact.js file that sits in functions/api/ directory. import { escapeHtml } from '../../_helpers/escape-html.js'; /** * Cloudflare Pages Function that powers the contact form. * * High‑level flow (perfect for a blog diagram): * * 1. Accept a classic `` POST (urlencoded or multipart). * 2. Validate the payload on the server (required fields + length limits). * 3. Verify a Cloudflare Turnstile token server‑side. * 4. Send a notification email via Resend (HTTPS API call). * 5. Redirect the user to a static “thanks” page. * * The matching HTML form does *not* need JavaScript – the worker stands on its * own. Client‑side JS (for validation + Turnstile widget) is purely a UX bonus. */ export const onRequestPost = async ({ request, env }) => { try { // --------------------------------------------------------------------- // 1. Only accept traditional browser form submissions // --------------------------------------------------------------------- // The contact page posts using `application/x-www-form-urlencoded`, but // we also support `multipart/form-data` so the handler works with // progressive enhancement and file inputs if they ever appear. const contentType = request.headers.get('content-type') || ''; if (!contentType.includes('application/x-www-form-urlencoded') && !contentType.includes('multipart/form-data')) { return new Response(JSON.stringify({ error: 'Unsupported content type' }), { status: 415, headers: { 'content-type': 'application/json' }, }); } // This gives us a `FormData` instance regardless of which of the two // encodings the browser chose. const form = await request.formData(); // --------------------------------------------------------------------- // 2. Honeypot – cheap, early bot filter // --------------------------------------------------------------------- // The form includes a visually hidden checkbox with this wonderfully // ugly name. Real users never see or tick it, but naive bots often // will. If it has a value, we quietly redirect back to the homepage. if (form.get('_its-a-trap!')) { const hpUrl = new URL('/', request.url); return Response.redirect(hpUrl.toString(), 303); } const name = (form.get('name') || '').toString().trim(); const email = (form.get('email') || '').toString().trim(); const message = (form.get('message') || '').toString().trim(); const redirectUrl = (form.get('_redirect') || '/contact/thanks/').toString(); // --------------------------------------------------------------------- // 3. Field‑level validation (server‑side, regardless of JS on client) // --------------------------------------------------------------------- if (!name || !email || !message) { return new Response(JSON.stringify({ error: 'Missing required fields' }), { status: 400, headers: { 'content-type': 'application/json' }, }); } // Upper bounds on field length keep logs and emails sane and protect // downstream services from unexpectedly huge payloads. if (name.length > 200 || email.length > 320 || message.length > 5000) { return new Response(JSON.stringify({ error: 'Invalid field lengths' }), { status: 400, headers: { 'content-type': 'application/json' }, }); } // --------------------------------------------------------------------- // 4. Verify Cloudflare Turnstile token (server‑side) // --------------------------------------------------------------------- // The front‑end Turnstile widget places its token into // `cf-turnstile-response`. Without a token, we *always* reject the // submission – even if the rest of the payload looks valid. const turnstileToken = form.get('cf-turnstile-response'); if (!turnstileToken) { return new Response(JSON.stringify({ error: 'Turnstile token is required' }), { status: 400, headers: { 'content-type': 'application/json' }, }); } // The Turnstile secret is never exposed to the client – it lives in // Cloudflare Pages environment variables. const secret = env.TURNSTILE_SECRET_KEY; if (!secret) { return new Response( JSON.stringify({ error: 'Server not configured for CAPTCHA (missing TURNSTILE_SECRET_KEY)', }), { status: 500, headers: { 'content-type': 'application/json' } } ); } // Including the remote IP is optional but recommended; it gives // Turnstile a little more context when scoring the request. const remoteIp = request.headers.get('CF-Connecting-IP') || request.cf?.clientAddress || null; const verifyBody = { secret, response: turnstileToken.toString(), }; if (remoteIp) { verifyBody.remoteip = remoteIp; } const verifyResp = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(verifyBody), }); if (!verifyResp.ok) { return new Response(JSON.stringify({ error: 'Failed to verify Turnstile' }), { status: 502, headers: { 'content-type': 'application/json' }, }); } const verifyJson = await verifyResp.json(); // If Turnstile says “no”, do not send an email – fail fast here. if (!verifyJson.success) { const errorCodes = verifyJson['error-codes'] || []; return new Response( JSON.stringify({ error: 'CAPTCHA verification failed', details: errorCodes.length > 0 ? errorCodes.join(', ') : 'Unknown error', }), { status: 400, headers: { 'content-type': 'application/json' } } ); } // Optional defence‑in‑depth check: ensure the token we received was // minted for this hostname (or one of its subdomains). const requestHost = new URL(request.url).hostname; if ( verifyJson.hostname && verifyJson.hostname !== requestHost && !requestHost.endsWith(`.${verifyJson.hostname}`) ) { console.warn(`Turnstile hostname mismatch: expected ${requestHost}, got ${verifyJson.hostname}`); } // --------------------------------------------------------------------- // 5. Build notification email payload for Resend // --------------------------------------------------------------------- // `fromEmail` must be a sender you control and have verified with Resend. const fromEmail = 'email@example.com'; const subject = form.get('_email.subject')?.toString() || 'New Message from Nooshu.com'; const textBody = `New contact form submission on nooshu.com\n\nName: ${name}\nEmail: ${email}\n\nMessage:\n${message}`; const htmlBody = `

New contact form submission on nooshu.com

Name: ${escapeHtml(name)}
Email: ${escapeHtml(email)}

Message:

${escapeHtml(message)}
`; // Resend API key comes from Cloudflare Pages environment variables, // never from the client or build‑time `.env`. const apiKey = env.RESEND_API_KEY; if (!apiKey) { return new Response( JSON.stringify({ error: 'Server not configured for email (missing RESEND_API_KEY)', }), { status: 500, headers: { 'content-type': 'application/json' } } ); } // Call the Resend REST API. In this project we make a single, // straightforward `emails` call – no templates, CC/BCC, or attachments. const resendResp = await fetch('https://api.resend.com/emails', { method: 'POST', headers: { 'content-type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ from: `Nooshu Contact <${fromEmail}>`, to: ['email@example.com'], reply_to: email, subject, text: textBody, html: htmlBody, }), }); if (!resendResp.ok) { const text = await resendResp.text(); return new Response(JSON.stringify({ error: 'Email send failed', details: text }), { status: 502, headers: { 'content-type': 'application/json' }, }); } // --------------------------------------------------------------------- // 6. Redirect to “thanks” page // --------------------------------------------------------------------- // Using a 303 ensures the follow‑up request is a GET, which means // browser refreshes do not re‑POST the form. const finalRedirect = new URL(redirectUrl, request.url); return Response.redirect(finalRedirect.toString(), 303); } catch (err) { return new Response(JSON.stringify({ error: 'Server error', details: String(err) }), { status: 500, headers: { 'content-type': 'application/json' }, }); } }; A version with fewer comments is available in this gist on GitHub. Amazingly, that's really the only two major changes! The minor changes were: Footer template: Update my footer with a link to the JavaScript above and the Turnstile JavaScript, e.g. https://challenges.cloudflare.com/turnstile/v0/api.js. CSP: Modified my _headers file to remove Google from my CSP and add in the challenges URL (https://challenges.cloudflare.com/) to the connect-src, script-src-elem, and frame-src. CSS: Slightly modify the CSS to remove Google ReCAPTCHA v3 specific styling (.grecaptcha-badge) and add Turnstile CSS (.turnstile-widget). Secret Keys: RECAPTCHA_SECRET_KEY swapped out for the TURNSTILE_SECRET_KEY in the Cloudflare Pages environment variables settings page. Contact Form HTML: HTML changes to the contact form added cf-turnstile-response hidden and #turnstile-container
. Why change? So I've literally done a 180 from Google ReCAPTCHA v3 to Cloudflare Turnstile in less than a week. So why change and what are the differences? The top of my priority list that I admittedly should have considered when integrating Google ReCAPTCHA v3 is privacy! I would rather not subject any readers of the blog to any additional tracking from Google! But there are other differences too: Area Cloudflare Turnstile advantage over reCAPTCHA v3 Why it matters Privacy Turnstile is positioned as a privacy preserving alternative, and it does not rely on tracking user data across sites for ad retargeting. It also offers Ephemeral IDs, which are short-lived device identifiers that work without cookies or client-side storage. Better fit for teams with strong privacy, GDPR, or public sector concerns. Less operational tuning Turnstile’s server validation is a straightforward success or failure check. By contrast, reCAPTCHA v3 returns a score and Google recommends that you review scores in the admin console and tune thresholds based on your own traffic, starting from 0.5. Usually simpler to ship and maintain, especially for smaller teams that do not want to spend time calibrating fraud thresholds. No CAPTCHA experience by default Turnstile works without showing visitors a CAPTCHA and supports invisible, non-interactive, and managed modes. reCAPTCHA v3 is also frictionless, but Turnstile is explicitly built as a CAPTCHA replacement with more presentation modes for the widget itself. More control over UX and easier to keep forms feeling clean while still validating traffic. Can be used on any site without Cloudflare CDN Turnstile can be embedded into any website, it does not require your traffic to go through Cloudflare or use Cloudflare’s CDN. Easier adoption if you want the bot check without changing your hosting or edge setup. Developer-friendly testing Turnstile provides dummy site keys and secret keys for testing, and Cloudflare documents that these work on localhost and any development domain. Google’s reCAPTCHA FAQ says for v3 you should create a separate key for testing environments, and scores may not be accurate because v3 relies on real traffic. Usually a smoother local dev and automated test setup. Free plan positioning Cloudflare documents Turnstile has a Free plan intended for personal sites, SMBs, dev and test, and most production applications. Google’s FAQ notes quota limits for non-Enterprise reCAPTCHA, and if a v3 key exceeds a monthly quota it may fail open with a static score of 0.9 for the remainder of the month. Attractive when cost certainty matters or when you want fewer surprises as usage grows. Extra integration option with Cloudflare security stack Turnstile supports pre-clearance, letting it issue clearance cookies that can be used across Cloudflare protected domains. Helpful if you already use Cloudflare WAF or bot controls and want tighter integration. Summary Well, that brings us to the end of another blog post, I only went off on a tangent a couple of times! So well done for sticking with my ramblings! In the end, the goal was to keep the architecture simple while still covering the essentials. The site itself remains fully static, with a lightweight Cloudflare Worker handling form submissions. Spam protection is provided through Google reCAPTCHA v3, Cloudflare Turnstile, while validation and accessibility patterns ensure the form behaves reliably for real users. It is a small piece of functionality, but implemented carefully it can be both robust and easy to maintain. As always, thanks for reading, and I’d love to hear your feedback. You are welcome to contact me either via the contact form (again, very meta! 😏), or via any of the various social channels listed on the site. Post changelog: 09/03/26: Initial post published. 13/03/26: Updates to give instructions on how to use Cloudflare Turnstile as an alternative to Google reCAPTCHA v3. Thanks again to Ryan Townsend and Paweł Grzybek. --- End: Using Cloudflare Workers and reCAPTCHA v3 for a Static Site Contact Form --- Start: Precompressed HTML at the Edge: Eleventy Meets Cloudflare Workers Published on: 21 February 2026 https://nooshu.com/blog/2026/02/21/precompressed-html-at-the-edge-eleventy-meets-cloudflare-workers/ Main Content: Introduction In 2025, I wrote a series of web performance optimisation blog posts focussing on some of the key fundamental's of Frontend Web Performance: Caching Asset fingerprinting and the preload response header in 11ty Summary The blog post describes how the I enhanced web performance on my 11ty-built site by combining asset fingerprinting with the HTTP preload hint. It explains that preload tells the browser to fetch critical resources earlier, but hashed filenames make this difficult to manage manually. The solution was to generate preload Link headers automatically during the 11ty build. A custom script locates the fingerprinted CSS file and injects the correct preload header into the Cloudflare Pages _headers file. This speeds up CSS delivery, removes the need for manual updates, and allows the use of long-lived Cache-Control header values such as max-age=31536000 and immutable. Compression Cranking Brotli up to 11 with Cloudflare Pro and 11ty. Summary The blog post explains how I improved the performance of my 11ty-powered blog after migrating to Cloudflare Pages by using Brotli compression at the highest level (11) for static assets. I also describes the difference between Brotli and gzip, outline how Cloudflare’s Pro plan typically applies a moderate Brotli level (4), and then show how to pre-compress JavaScript files to Brotli level 11. Lastly, serve them correctly both locally and via Cloudflare, and configure Cloudflare’s compression rules so that all assets benefit from the stronger compression to reduce file sizes and improve load performance. Concatenation Using an 11ty Shortcode to craft a custom CSS pipeline Summary While not strictly about concatenation, it covers related ideas. The post explains how I built a custom CSS pipeline for my 11ty site using a bespoke short code rather than the default bundle plugin. It details how I preserved local live reload, added content-based fingerprinting for long-term caching, minified CSS with clean-css package, enabled Brotli compression, and used disk and memory caching to prevent unnecessary work. The build also generates hashed filenames and matching HTML link tags, ensuring production serves fully optimised, cache friendly CSS automatically. More compression In this blog post, I’m going to take the compression a little further. I already have CSS and JavaScript Brotli compressed to the highest level (11) and served from Cloudflare Pages. But what about the third and final core technology of the web? Arguably the most important too… The HTML. In this post, I will look at how to compress your HTML to 11 during the 11ty build phase, and what modern technologies you need in order to make this work (it’s not as straightforward as I thought!) Why HTML Brotli matters? Before we dive into the details, let’s discuss why Brotli compression is important for HTML. Well, to summarise Brotli compression in one sentence: Brotli compression reduces file size by intelligently identifying repeated patterns in data and encoding them more efficiently using a combination of modern compression techniques. This is fantastic for anything with repeating patterns as the bytes saved over the network can be huge! The great thing about HTML is that it has a tonne of repeating patterns (e.g. Markup)! This reduction in file size could potentially equate to: Improved Web Performance Lower Time To First Byte impact on slow networks Improved First Contentful Paint Improved Largest Contentful Paint Reduced Total Blocking Time (indirectly) Every kilobyte saved here multiplies across your traffic volume. At scale, cost savings add up Let’s assume you are serving a high traffic website, say the BBC, Google, or GOV.UK. Imagine how much bandwidth could be saved by simply compressing your HTML. Any percentage saving on millions of requests per day is going to mean: Less bandwidth Lower CDN egress Lower cloud costs Lower carbon footprint This is a win both commercially and environmentally! Improved performance on slow and unstable mobile networks Brotli 11 improves the web for users where they need it the most: 3G connectivity High latency rural connections Congested public networks International access HTML blocks everything else. If you shrink it aggressively, you unblock the page faster, meaning users get a better experience. This is a real-world performance gain. It indirectly improves Core Web Vitals Smaller HTML means: Faster DOM construction Earlier CSS discovery Earlier JS discovery Reduced main thread idle gaps This is especially important for server rendered pages or hybrid Server-side Rendered apps. Compression cost is paid once for static assets Yes, level 11 compression is expensive to compute. But this cost is paid back over time. You pay for the CPU time once. Users benefit forever assuming: the HTML is static and cacheable at the Content Delivery Network (CDN) using long-life caching headers you automate and pre-compress the HTML at build time Compression Size Examples v1 Let’s have a look at a huge HTML page on the web. My go-to for this is either the W3C HTML5 Specification page OR NASA’s Astronomy Picture of the Day Archive. Rather than choose, let’s just compress both! W3C HTML5 Specification (Single Page) URL: https://www.w3.org/TR/2011/WD-html5-20110405/Overview.html Uncompressed size: 4.7 MB Brotli (Level 11): 590 KB (~88% saving) That’s an 88 percent reduction in bytes over the network when compressing the HTML with Brotli 11. Not bad for something that takes just over 10-seconds to run. And yes, that’s 4.7 MB of HTML alone. It’s an absolute beast of a page. For perspective, that would take around 12 to 15 minutes to download on a 56k modem in the late 90s. Just for the HTML. I might be showing my age here 😭 Nasa’s Astronomy Picture of the Day Archive URL: https://apod.nasa.gov/apod/archivepix.html Uncompressed size: 314 KB Brotli (Level 11): 53 KB (83% saving) We’ve taken it from roughly a third of a megabyte down to just 53 KB. That is a pretty substantial reduction! This means faster page loads, lower data usage, and a much smoother experience for anyone on a limited data plan or dealing with patchy connections. A very positive impact, right where it matters most for users. How is this different from my other compression posts? So how is this different from the Brotli compression I have mentioned in the previous blog posts? In Cranking Brotli up to 11 with Cloudflare Pro and 11ty I used a combination of: Brotli CLI (e.g.brew install brotli) bash scripts (compress.sh, compress-directory.sh) In this post, I override the Cloudflare Dashboard's "Compression Rules” (which dynamically compresses HTML at Brotli level 4). The CDN is compressing the HTML on-the-fly when the user’s browser requests it. What this blog post describes is pre-compression to Brotli 11 at 11ty build time. To do this the workflow is: Apply Brotli level 11 pre-compression to HTML during the 11ty build on Cloudflare Pages. _helpers/html-compression.js runs after the site is written to _site. Each HTML file gets a matching .br file Use the built-in Node zlib module, so no manual setup, CLI tools, scripts, Cloudflare configuration, or extra dependencies are required. Take advantage of Cloudflare Pages Functions, which run on Cloudflare Workers. This is a great opportunity to use a modern, fast evolving platform that opens the door to powerful edge capabilities. What’s the point? Well, that’s a great question. As some readers may know by default Cloudflare compresses HTML at compression level 4. Now, if we compare this level to the compression level 11 above, let’s have a look at the difference: Compression Size Examples v2 W3C HTML5 Specification (Single Page) URL: https://www.w3.org/TR/2011/WD-html5-20110405/Overview.html Uncompressed size: 4.7 MB Brotli (Level 4): 729 KB (83% saving) Compression Time (Level 4): 0.149 seconds Brotli (Level 11): 590 KB (87% saving) Brotli (Level 11): 11.717 seconds Nasa’s Astronomy Picture of the Day Archive URL: https://apod.nasa.gov/apod/archivepix.html Uncompressed size: 314 KB Brotli (Level 4): 66 KB (79%) Compression Time (Level 4): 0.011 seconds Brotli (Level 11): 53 KB (83% saving) Brotli (Level 11): 0.743 seconds It’s worth noting that I used Paul Calvano's fantastic Compression Tester Tool, to help with this basic analysis. What you will likely notice is that even for large HTML files the size difference between compression level 4 and compression level 11 isn’t huge, only 12 KB in the W3C HTML5 Specification example. The real difference comes in computation time. Level 4 0.149 seconds verses 11.717 seconds! That’s a 7764% increase in time between Level 4 and Level 11! Although this is an extreme example, you can probably see why Level 11 isn’t used by Cloudflare for on-the-fly compression of HTML assets. Level 4 gives a good balence between file compression versus compression speed. I’m betting countless smart people were involved in the analysis of using Level 4 by default! When you are a company that is literally serving billions of requests per second, this decision really makes a difference in terms of processing power infrastructure and power usage! Thankfully, the way that I have implemented level 11 compression on my blog, processing time doesn’t really matter. All the HTML is being compressed at 11ty build time. As I said above, the cost of this additional CPU time is paid back over time by users getting a better experience (even if only slightly). Furthermore, remember there’s a slight reduction in storage required on the CDN. From my perspective, if it is low effort after setup, it feels like the right move to implement it. Problems As I found out during implementation it isn’t just as simple as compressing the HTML to 11 and setting a static Content-Encoding header for HTML in the _headers file (trust me, I tried it!) Problem 1: URL Path vs Actual File Path Mismatch When serving static assets like CSS, JS, or images, there is usually a simple one-to-one relationship between the URL and the file on disk. A request to /css/site.css maps directly to _site/css/site.css. No extra logic is required because the URL path matches the file path exactly. I had no idea, but I soon found out that HTML pages behave differently. A request to / or /blog/post/ does not correspond to a literal file at that path. Instead, the server applies a convention and serves index.html inside that directory. So /blog/post/ actually maps to blog/post/index.html on disk. This mapping happens automatically when Cloudflare serves uncompressed HTML through its very efficient static asset layer. The problem appears when serving pre-compressed Brotli files. You cannot simply request the same URL and expect the .br file to resolve. Instead, the Cloudflare Function must manually translate the directory style URL into the real file path before appending .br. For example, / becomes /index.html.br, /blog/post/ becomes /blog/post/index.html.br, and /404.html becomes /404.html.br. In short, HTML requires explicit path translation because the browser sees a directory style URL while the actual file stored on disk is index.html. The Cloudflare Function must bridge that gap to correctly serve the Brotli 11 compressed version, rather than the uncompressed HTML version. It actually makes sense now that I think about it, I’ve always just taken the automatic appending of index.html to a URL Path for granted! The fact that as a user on the web doesn’t even have to think about that small detail, shows how well it works! As Dieter Rams once said: Good design is as little design as possible. Problem 2: A page full of Wingdings I’m showing my age again, but for readers who don’t remember early versions of Windows (e.g. 3.1), it came bundled with a font called Wingdings. This True Type font contains many largely recognised shapes and gestures as well as some recognised world symbols. Wingdings were an early symbol font that experimented with pictographic digital symbols, that would later lead on to ASCII emoticons like :-) & ¯\_(ツ)_/¯, which in turn would progress to the modern world of Emoji’s! Essentially, what was happening the Cloudflare server was serving raw Brotli compressed HTML files to the browser, expecting it to understand what these (now binary, not text) files were, and how to read and understand them. I was essentially serving the HTML without the following headers: Content-Type: text/html; charset=UTF-8 Content-Encoding: br Vary: Accept-Encoding Here’s a brief explanation of these headers: Content-Encoding: br: This is telling the browser “What I’m sending you is a Brotli compressed file, you are going to need to decode it before you understand it”. Content-Type: text/html; charset=UTF-8: This tells the browser what character set ('charset') it should use after decompression, this is essential as this is key to the browser parsing the HTML correctly. Vary: Accept-Encoding only affects caching (e.g. Content Delivery Networks). It’s basically saying to the cache to store separate versions of this file depending on the Accept-Encoding header. The result of the above gave me a homepage that looked like the image below (interesting but not exactly readable!): My Approach Step 1: Build-time compression After the site is generated, it moves into a final preparation stage before going live. During this stage, the system goes through all the finished HTML pages and creates highly compressed versions of them. These compressed files sit alongside the originals and are ready to be served immediately. Because this happens as part of the 11ty build process, every release automatically includes freshly optimised files. This means the live site can deliver pages faster, with smaller file sizes and no need to compress anything on the fly (e.g. what Cloudflare does with its HTML compression to Brotli level 4). The result is better performance for users, with no extra overhead once the site is hosted and running on Cloudflare pages. Step 2: Cloudflare Pages Function for content negotiation When someone visits a page on the site, a lightweight Cloudflare Function (via a Cloudflare Worker) checks whether a users browser supports modern compression. If it does, the system serves the Brotli 11 pre compressed version of the page. This keeps file sizes small and pages loading quickly. If the browser does not support this compression, or if a compressed version is not available, the system simply serves the standard uncompressed version of the HTML instead. No extra processing happens at this stage. The edge layer (Cloudflare Function + Worker) is only deciding which version of the already prepared files to send. All optimisation work has already been done earlier in the 11ty build process. The final result is fast delivery, efficient bandwidth use, and a simple, reliable build setup. Everyone wins! Step 3: The Eleventy build uses Node.js zlib, for seamless integration As mentioned earlier, this Brotli implementation differs from others I have used. Instead of relying on bash scripts such as compress.sh or compress-directory.sh, it uses Node.js’s built in zlib module. Because zlib is part of Node.js core, it is stable, well maintained, and requires no external dependencies. It has been available since the earliest Node.js releases, so it is a sensible default choice. I may even revisit the other build process in future and consider replacing the remaining bash scripts with a fully Node.js based approach. Implementation Next, let’s stop the waffling and get onto the actual implementation, I assume that’s what readers are here for after all! Core compression utility Here is the main compression file that is used to compress the HTML to Brotli 11: /** * Core compression utility: Brotli compression for build-time pre-compression. * * This module is the single source of truth for Brotli in the project. It is used by: * - html-compression.js — compresses all HTML in _site to .br (level 11) * - css-manipulation.js — compresses processed CSS to .br when writing to _site * - js-compression.js — compresses minified JS in _site/js to .br * * All compression happens during the Eleventy production build (postbuild phase). * Pre-compressed .br files are then served via content negotiation in functions/[[path]].js * when the client sends Accept-Encoding: br, avoiding any runtime or CDN dynamic compression. */ import { brotliCompressSync } from 'zlib'; /** * Default Brotli compression level. * * Brotli levels range from 0 to 11: * - 0–3: Fast, lower ratio (typical for dynamic/on-the-fly compression; e.g. CDNs often use 4). * - 11: Maximum ratio, slowest; ideal for static assets compressed once at build time. * * We use 11 because compression runs only during the build, so CPU cost is paid once per * deploy. The resulting .br files are then served as-is with no re-compression at the edge. */ export const BROTLI_LEVEL = 11; /** * Compress input data with Brotli. * * Uses Node's synchronous zlib API so callers can use the function in a simple, blocking * way during the build (no need to await). Input is normalized to a Buffer so that * strings (e.g. file contents read as UTF-8) and TypedArrays are supported. * * @param {Buffer | Uint8Array | string} input - Data to compress. If a string, it is * encoded as UTF-8. Buffers and Uint8Array are used as-is (after copying to a Buffer * when necessary via Buffer.from). * @param {number} [level=BROTLI_LEVEL] - Compression level 0–11. Defaults to 11 for * maximum ratio. Can be overridden (e.g. via BROTLI_COMPRESSION_LEVEL in css-manipulation). * @returns {Buffer} - Compressed data as a Buffer. Callers typically write this to a * file with a .br extension (e.g. index.html.br). */ export function brotliCompress(input, level = BROTLI_LEVEL) { const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input); return brotliCompressSync(buffer, { level }); } I appreciate that not everyone prefers heavily commented code, so there is also a version in this Gist with minimal comments and improved readability. HTML compression post-build The post-build HTML compression file: /** * HTML Brotli compression (postbuild). * * This module compresses every HTML file in the Eleventy output directory (_site) to * Brotli level 11 and writes a corresponding .br file alongside each .html file * (e.g. index.html → index.html.br, blog/post/index.html → blog/post/index.html.br). * * When it runs: * Only during the production postbuild phase, after Eleventy has finished writing * _site. It is invoked from _config/build-events.js in the eleventy.after handler * (alongside JS minification, JS Brotli, and preload header generation). * * How .br files are served: * The Cloudflare Pages Function functions/[[path]].js performs content negotiation * for HTML document requests. When the client sends Accept-Encoding: br, the Function * fetches the pre-built .br asset (e.g. / → index.html.br) and returns it with * Content-Encoding: br. Clients that do not advertise Brotli support receive the * uncompressed .html from the static asset bucket. No dynamic compression runs at * the edge; this step does all compression once at build time. * * Uses the core compression utility _helpers/compression.js for the actual Brotli call. */ import fs from 'fs'; import path from 'path'; import { brotliCompress, BROTLI_LEVEL } from './compression.js'; /** * Recursively find all .html files under a directory. * * Used to discover every HTML file in _site (root index.html, blog posts, portfolio * items, 404.html, etc.). Paths are returned as relative to _site so that we can * join them with siteDir for full paths and still have readable relative paths for * logging and error messages. * * @param {string} dir - Absolute or relative directory path to search (e.g. ./_site or a subdir). * @param {string[]} [acc=[]] - Accumulator array; results are pushed here during recursion. * @returns {string[]} Relative paths to .html files (e.g. ['index.html', 'blog/post/index.html']). */ function findHtmlFiles(dir, acc = []) { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); const relPath = path.relative('./_site', fullPath); if (entry.isDirectory()) { findHtmlFiles(fullPath, acc); } else if (entry.isFile() && entry.name.endsWith('.html')) { acc.push(relPath); } } return acc; } /** * Compress all HTML files in _site with Brotli level 11, writing .br files. * * Call this only after the Eleventy build has completed and _site is fully written. * For each .html file: reads content, compresses with brotliCompress (level 11), * writes .br next to the original. Existing .br files are skipped if their * mtime is >= the source .html mtime (incremental safety; in a full build all HTML is * usually newer, so most files are compressed). Logs counts, total bytes saved, and * duration; collects per-file errors without stopping the loop. */ export function compressHtmlFiles() { const startTime = Date.now(); console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log('🚀 PRODUCTION POSTBUILD: Starting HTML Brotli compression'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); const siteDir = path.join('./_site'); if (!fs.existsSync(siteDir)) { console.log('⚠️ _site directory not found, skipping HTML compression'); return; } const htmlFiles = findHtmlFiles(siteDir); if (htmlFiles.length === 0) { console.log('⚠️ No HTML files found, skipping compression'); return; } let compressedCount = 0; let skippedCount = 0; let totalOriginal = 0; let totalCompressed = 0; const errors = []; for (const relPath of htmlFiles) { const inputPath = path.join(siteDir, relPath); const outputPath = `${inputPath}.br`; try { // Skip writing if .br already exists and is not older than the source .html if (fs.existsSync(outputPath)) { const inputStats = fs.statSync(inputPath); const outputStats = fs.statSync(outputPath); if (outputStats.mtime >= inputStats.mtime) { skippedCount++; continue; } } const fileContent = fs.readFileSync(inputPath); const originalSize = fileContent.length; const brotliBuffer = brotliCompress(fileContent, BROTLI_LEVEL); const compressedSize = brotliBuffer.length; fs.writeFileSync(outputPath, brotliBuffer); compressedCount++; totalOriginal += originalSize; totalCompressed += compressedSize; } catch (error) { errors.push(`❌ ${relPath}: ${error.message}`); } } const totalTime = Date.now() - startTime; const savedPercent = totalOriginal > 0 ? ((1 - totalCompressed / totalOriginal) * 100).toFixed(1) : '0'; console.log(`✅ Compressed ${compressedCount} HTML file(s) (${skippedCount} skipped, up-to-date)`); if (compressedCount > 0) { console.log( ` ${(totalOriginal / 1024).toFixed(1)} KB → ${(totalCompressed / 1024).toFixed(1)} KB (${savedPercent}% smaller)` ); } console.log(` Finished in ${totalTime}ms (${(totalTime / 1000).toFixed(2)}s)`); if (errors.length > 0) { console.error('\nErrors:'); errors.forEach(e => console.error(` ${e}`)); } console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); } A version with minimal comments and much improved readability is available in a Gist here. Cloudflare Pages Function for content negotiation Here we have the Cloudflare Function file that runs in the Cloudflare Worker. It is located in the /functions directory in the root of the repository so it can be detected when Cloudflare Pages builds. /** * Catch-all Cloudflare Pages Function: HTML document content negotiation. * * This Function runs for every GET and HEAD request that is not handled by a more * specific route (e.g. POST /api/contact is handled by functions/api/contact.js). * Its job is to serve pre-compressed Brotli (.br) HTML when the client supports it, * and otherwise pass through to the static asset bucket (uncompressed HTML). * * Pre-compressed .br files are produced at build time by _helpers/html-compression.js * (postbuild): every .html in _site gets a matching .br (e.g. index.html.br, * blog/post/index.html.br). This Function does not compress on the fly; it only * chooses which asset to serve and sets the correct response headers. * * Flow: * 1. If the request is not for an HTML document URL (see below), pass through to ASSETS. * 2. If the client does not send Accept-Encoding: br, pass through (serve uncompressed HTML). * 3. Otherwise, fetch the corresponding .br asset; if missing or error, fall back to ASSETS * for the original request (uncompressed). * 4. If .br exists, return it with Content-Encoding: br and no-transform so nothing * re-compresses the body. */ async function handleHtmlWithBrotli(request, env) { const url = new URL(request.url); const pathname = url.pathname; // Only treat as HTML document: root (/), directory-style paths ending in /, or 404 // (Cloudflare Pages maps /404 to /404.html). All other paths (e.g. /script.js, // /style.css, /favicon.ico) go straight to static assets; Pages serves .br for // static files when available and client sends Accept-Encoding: br. const isHtmlDocument = pathname === '/' || pathname.endsWith('/') || pathname === '/404.html'; if (!isHtmlDocument) { return env.ASSETS.fetch(request); } const acceptsBrotli = request.headers.get('Accept-Encoding')?.includes('br') ?? false; if (!acceptsBrotli) { return env.ASSETS.fetch(request); } // Map URL path to the .br file in the asset bucket. Eleventy outputs directory // indexes as index.html (e.g. /blog/post/ → blog/post/index.html), so the .br // file is index.html.br. const brPath = pathname === '/' ? '/index.html.br' : pathname === '/404.html' ? '/404.html.br' : `${pathname}index.html.br`; const brUrl = new URL(brPath, url.origin); const brResponse = await env.ASSETS.fetch(brUrl.toString()); if (!brResponse.ok) { return env.ASSETS.fetch(request); } // Build response from the .br body with headers that declare Brotli and prevent // any downstream re-compression or transformation. const headers = new Headers(brResponse.headers); headers.set('Content-Encoding', 'br'); headers.set('Content-Type', 'text/html; charset=UTF-8'); headers.set('Vary', 'Accept-Encoding'); // no-transform: tells caches (including Cloudflare) not to re-encode or modify the body headers.set('Cache-Control', 'public, max-age=31536000, no-transform'); return new Response(brResponse.body, { status: brResponse.status, headers, // encodeBody: 'manual' — we are returning the body as-is (already Brotli). // Without this, the Workers runtime might try to compress the response again. encodeBody: 'manual', }); } export const onRequestGet = async ({ request, env }) => { return handleHtmlWithBrotli(request, env); }; export const onRequestHead = async ({ request, env }) => { return handleHtmlWithBrotli(request, env); }; A version with minimal comments and much better readability is available in a Gist here. Build lifecycle wiring This is the main build file I use to build my 11ty blog for production on Cloudflare Pages. /** * Build lifecycle wiring: registers Eleventy before/after hooks for production. * * This module is the single place where postbuild steps are scheduled. It is loaded * from the main Eleventy config (e.g. eleventy.config.js), which calls * registerBuildEvents(eleventyConfig). Handlers run only when env.isLocal is false * (i.e. production or preview builds); local dev builds skip all of this so that * _site is left as plain output and the dev server stays fast. * * Order of operations: * - eleventy.before: clear CSS build cache so CSS is regenerated and Brotli-compressed * from scratch when needed. * - eleventy.after: run the production postbuild phase in a fixed order: * 1. generatePreloadHeaders() — write Link headers into _headers for CSS (and .br). * 2. minifyJavaScriptFiles() — minify JS in _site/js (must run before Brotli). * 3. compressJavaScriptFiles() — Brotli-compress JS to .br. * 4. compressHtmlFiles() — Brotli-compress all HTML in _site to .br. * Then: destroy HTTP/HTTPS global agents to avoid hanging connections, and on * production only force process.exit(0) after a short delay so the process * terminates cleanly on CI/Cloudflare Pages. */ import env from '../_data/env.js'; import { clearCssBuildCache } from '../_helpers/css-manipulation.js'; import { generatePreloadHeaders } from '../_helpers/header-generator.js'; import { compressHtmlFiles } from '../_helpers/html-compression.js'; import { compressJavaScriptFiles } from '../_helpers/js-compression.js'; import { minifyJavaScriptFiles } from '../_helpers/js-minify.js'; /** * Register eleventy.before and eleventy.after handlers. * Only registered when !env.isLocal (production/preview). * @param {import("@11ty/eleventy").UserConfig} eleventyConfig */ export function registerBuildEvents(eleventyConfig) { if (env.isLocal) { return; } // Before build: clear any cached CSS build artifacts so this run produces fresh // processed and Brotli-compressed CSS when templates reference CSS. eleventyConfig.on('eleventy.before', () => { clearCssBuildCache(); }); eleventyConfig.on('eleventy.after', async () => { console.log('\n═══════════════════════════════════════════════════════════════════════════════'); console.log('🚀 PRODUCTION POSTBUILD PHASE: Beginning postbuild operations'); console.log('═══════════════════════════════════════════════════════════════════════════════\n'); generatePreloadHeaders(); await minifyJavaScriptFiles(); compressJavaScriptFiles(); compressHtmlFiles(); console.log('\n═══════════════════════════════════════════════════════════════════════════════'); console.log('✅ PRODUCTION POSTBUILD PHASE: All postbuild operations completed'); console.log('═══════════════════════════════════════════════════════════════════════════════\n'); // Tear down Node's default HTTP/HTTPS agents so the process can exit without // waiting for keep-alive connections to time out (e.g. on Cloudflare Pages CI). console.log('🔧 Forcing cleanup of HTTP connections and timers...'); await new Promise(resolve => setTimeout(resolve, 100)); try { const http = await import('http'); const https = await import('https'); if (http.globalAgent) { http.globalAgent.destroy(); console.log('✅ Destroyed HTTP global agent'); } if (https.globalAgent) { https.globalAgent.destroy(); console.log('✅ Destroyed HTTPS global agent'); } } catch (error) { console.warn('⚠️ Could not destroy HTTP agents:', error.message); } await new Promise(resolve => setTimeout(resolve, 50)); console.log('✅ HTTP connection cleanup completed'); // On production builds only, force exit after a short delay so the runner // (e.g. Cloudflare Pages) gets a clean exit code and doesn't hang. if (env.isProd) { console.log('🏁 Production build complete - forcing process exit in 2 seconds...'); setTimeout(() => { console.log('👋 Forcing clean exit now'); process.exit(0); }, 2000); } }); } A version with minimal comments and much improved readability is available in a Gist here. Other Technical details worth mentioning Here are 4 other small technical details worth explaining as part of the implementation: Why encodeBody: 'manual' is required The content we send back to the user's browser is already compressed using Brotli. It is being loaded from a file that has already been compressed in advance. If we don't tell Cloudflare to leave it alone, it may assume the content is not compressed and try to compress it again. Compressing something that is already compressed can cause problems and may result in a broken or unreadable output, plus it is a waste of CPU time and resources. By setting encodeBody: ‘manual', we are telling Cloudflare to send the content exactly as it is, without changing it. This ensures the pre-compressed file is delivered correctly to the user's browser. Why Vary: Accept-Encoding and Cache-Control: no-transform matter These 2 response headers are critical for making the pre-compression work. I've given details as to why that is below: Vary: Accept-Encoding: This notifies browsers and CDNs that the response can change depending on what kind of compression the browser supports. For example, if a browser says it supports Brotli, the cache will store and return the Brotli version for those requests. If another browser doesn't support Brotli, the cache will store and return a different version, such as an uncompressed version. This prevents the wrong format being sent to the wrong browser. Cache-Control: no-transform: This informs caches and other systems between the server and the user's browser not to modify the content. It asserts that the response should not be compressed again or altered in any way. Without this setting, a proxy might try to compress the content again, which can cause errors and waste processing power. With this header in place, the already compressed file is stored and delivered exactly as intended. Incremental build optimisation (runtime check to skip unchanged files) After the Eleventy build finishes, the post-build step recursively scans through the output folder, such as the _site directory, and checks each HTML file. Before compressing a file, it checks whether a matching .br file already exists and whether it is up-to-date. If the .br file is the same age or newer than the original file, it is skipped. If the page is new or has been updated, a fresh compressed version is created. This avoids pages that have not changed being needlessly recompressed, keeping the post build step fast. When only a few pages are updated, the need for recompression is limited. Why we need a Cloudflare Function instead of just the _headers file for HTML Brotli? 1. _headers can only change headers, not the file itself The _headers file lets us add or modify (but not remove) response headers. It doesn't control which file is actually sent back to the browser. So, when someone visits / or /blog/post/, Cloudflare Pages automatically serves index.html or blog/post/index.html. If we want to serve the Brotli version of the HTML, we need to send index.html.br instead. But the _headers file has no way to switch the file being served. 2. Setting the header alone is not enough Even if you add Content-Encoding: br in the static _headers file, the actual file being sent would still be the normal uncompressed version of the HTML. The browser would see this Brotli header and try to decompress the response. Since the content being sent isn't compressed, it would simply fail and the page would break. Results Looking at my build logs I can now see that compressing the HTML to Brotli 11 has had the following results: 📊 HTML Brotli 11 total savings: 123.4 KB (75.2% reduction) That’s not too bad a saving considering how simple it is to set up and integrate into the 11ty build process! Thankfully, now that it’s done I can just “set it and forget it!”. Let's examine the results from the DevTools Network panel below: DevTools Before DevTools After DevTools After Page Reload Curiously, when reloading the page with DevTools open, the HTTP status code changes from 200 to 304 and the Brotli compression in the Content-Encoding: br disappears. The reason for this is because either: The reload request doesn’t contain an Accept-Encoding: br header so the Cloudflare Worker is simply returning the uncompressed version of the HTML file as is expected. The 304 has no response body, so there’s nothing to show as being compressed. Difference in build times Thanks to Thomas Steiner on Mastodon for the reminder. It completely slipped my mind to share the difference in build time before and after adding HTML Brotli compression. Total Build time before: ~ 2 minutes 58 seconds Total Build time after: ~ 3 minutes 16 seconds Compressed: 485 HTML file(s) (0 skipped, up-to-date) Total time: 18.191299 seconds (about 18.19s) to Brotli compress all HTML files. Summary By pre-compressing this blog’s HTML during the 11ty build phase, I have reduced the number of bytes sent on each page load. While the savings are small for a low traffic site like mine, at scale across millions of users and billions of requests per day, this approach could deliver meaningful bandwidth reductions and incremental performance improvements. This is especially true where network speed and stability vary globally. Thank you for reading, I hope you found it useful. Edge Workers are an incredibly powerful technology. I genuinely look forward to using them again in the future. I always open to feedback and corrections. If you spot anything that needs fixing or is incorrect, please let me know. I will credit you in the post changelog below. Post changelog: 21/02/26: Initial post published. 24/02/26: Thanks to Thomas Steiner for nudging me to add details about the difference in build times! --- End: Precompressed HTML at the Edge: Eleventy Meets Cloudflare Workers --- Start: Asset fingerprinting and the preload response header in 11ty Published on: 02 September 2025 https://nooshu.com/blog/2025/09/02/asset-fingerprinting-and-the-preload-response-header-in-11ty/ Main Content: This blog post will be building on a number of blog posts that I wrote earlier in the year. These were the posts: Using an 11ty Shortcode to craft a custom CSS pipeline Cranking Brotli up to 11 with Cloudflare Pro and 11ty The Speed Trifecta: 11ty, Brotli 11, and CSS Fingerprinting 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 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 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. 1. Link in the head This is probably the easiest way to add a preload to a website. Stick it in the : 2. Preload link header 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: ; rel=preload; as=style Link: ; rel=preload; as=script OR Link: ; rel=preload; as=style, ; 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: Use a response header transform rule in the Cloudflare dashboard to remove the Link header from all other file types served except CSS. 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: CSS minified and Brotli compressed to level 11 (highest). CSS Asset fingerprinting to allow for long life cache-control headers, including immutable. 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. --- End: Asset fingerprinting and the preload response header in 11ty --- Start: Hack to the Future - Frontend Published on: 26 August 2025 https://nooshu.com/blog/2025/08/26/hack-to-the-future-frontend/ Main Content: Table of Contents Hack to the Future - Frontend 1. Introduction Context Looking back at "legacy" practices Lessons we can apply today 2. Setting the Time Circuits to the late 90s My first website build The late 90s web landscape 3. The Early Web - Layout and Design Practices Photoshop PSDs as the single source of truth Frame-Based Layouts Table-Based Layouts Quirks Mode Layouts Fixed Width Fonts for Responsive Text 4. The Plugin Era – Flash and Friends Flash-based content Scalable Inman Flash Replacement (sIFR) Cufón GIF Text Replacements Adobe AIR Yahoo Pipes PhoneGap / Apache Cordova Microsoft Silverlight Java Applets 5. The JavaScript Library Explosion DHTML Beginnings (1997) Prototype.js (2005) Script.aculo.us (2005) Dojo Toolkit (2005) Yahoo! User Interface (YUI) (2006) moo.fx (2005)) MooTools (2006) jQuery (core) (2006) Ext.js (2007) jQuery UI (2007) AngularJS (2010) Backbone (2010) Knockout (2010) 6. CSS Workarounds and Browser Quirks Old CSS practices Sliding Doors Technique Image Sprites for Icons Vendor Prefixes for CSS Heavy Use of !important in CSS OldIE hacks DOCTYPE fragility zoom: 1 hack Underscore Hack Asterisk Hack Star HTML Hack Child Selector hack Double Margin Float Bug Peekaboo bug fix Transparent PNG fix Lack of IE Developer Tools IE Conditional Comments IE CSS Selector Limit 7. Markup of the Past XHTML 1.1 and 2.0 Inline JavaScript Document.write() Fixed Viewport Meta Tags Web Safe Fonts Only (before @font-face) 8. Tools and Workflow Relics SVN (subversion, largely replaced by Git) Chrome Frame CSS Resets Hover-Only Interactions 9. Legacy Web Strategies Blackhat SEO "Above the Fold" obsession Superseded compatibility approaches Modernizr 10. Tests and Standards of Yesteryear Acid2 and Acid3 Tests 11. What Still Matters - Progressive Enhancement Not legacy but often forgotten What is Progressive Enhancement HTML CSS JavaScript Progressive Enhancement Summary Importance in government services 12. Lessons for the Future What these legacy practices teach us today Applying lessons to modern frontend work 13. Post Summary 1. Introduction Context So over the last few months at work, I've been conducting interviews to hire Frontend Developers for a number of new projects we have in the pipeline. It was only when looking at CV's that it struck me, a lot of these candidates weren't even born when I first started in my Web Development career! So I thought maybe developers getting into a Frontend Developer career today, may want to learn a bit about what it was like when I first started (that sentence just makes me feel old! 👴) Looking back at “legacy” practices Why would we want to look back on legacy best practices on the web? Other than the obvious academic and for general interest reasons? Studying past best practices and legacy systems is crucial for understanding the evolution of technology and making informed decisions today. By examining the problems old practices were designed to solve, we gain a deeper appreciation for current best practices and avoid repeating past mistakes. As the philosopher George Santayana once said:  Those who cannot remember the past are condemned to repeat it. This historical perspective also reveals enduring principles like progressive enhancement, which remains vital for creating accessible and resilient systems on the web. Lessons we can apply today For developers, understanding past methodologies is essential for properly maintaining and modernising existing systems in the future without causing critical failures. This historical knowledge will ultimately help them navigate the complexities of older codebases, to ensure they make informed decisions about how to update or replace components. Above all, reflecting on the past can help us come up with creative new ideas and prevent us from blindly following new trends. This perspective also provides a comprehensive view of how the web has evolved, grounding our current practices in a deeper understanding of the technology's history. This process of building on past knowledge is a fundamental aspect of human progress. Just as civilizations learn from historical events to avoid repeating mistakes, developers can learn from the successes and failures of past technological eras. It's how humanity has always evolved. By building upon the accumulated wisdom and experience of those who came before us. By studying the mistakes and triumphs of the past, we improve our own work and contribute to the continuous cycle of innovation and learning that drives our entire industry forward. 2. Setting the Time Circuits to the late 90s My first website build In 1998, while working toward my GCSEs, I became interested in art and design, this was partly thanks to having an art teacher as my form tutor throughout secondary school. That influence, combined with the opportunity to take a double art GCSE for the same effort as a single GCSE, made the choice a pretty easy one! GCSE Art, here I come! At the same time, I was already immersed in the emerging world of the internet, spending many hours online discovering a passion for many areas of computing and online gaming thanks to QuakeWorld Team Fortress, despite the frustration it caused at home by tying up the phone line all hours of the day, oh how I loved my US Robotics 56K modem, with its 120-150 ping! Integrated Services Digital Network (ISDN) or any form of broadband was still many years away for most people! I was never exactly blessed with traditional artistic talent, painting, drawing, all of those art forms just wasn’t my thing. But I spotted an opportunity to combine my love of technology with the art curriculum. Back then, there were only about 2.4 million websites in existence worldwide. Most businesses and schools (including mine), were firmly offline. So, I proposed building a website for my final art project. To my surprise, my art teacher was absolutely thrilled with the idea. It turned out to be a first for the school and, as I later discovered, a first for the entire exam board too. Shock horror: I was ahead of the curve once. The curve has been safely ahead of me ever since. I ended up creating a website for a fake record label, complete with a dreadful album cover, fictional artist, and made-up discography. Honestly, I wish I still had it! It was gloriously awful! I don’t recall much, but I remember the site used a with three elements. The top frame displayed the logo, the left frame held the navigation menu, and the main frame was used for the page content. The logo, by the way, was crafted in a program called 3D Text Studio (or something similar to that) that churned out spectacularly cheesy animated text like this! From a web performance perspective, that single GIF exceeded 2 MB. On a 56K modem, which was the standard connection for most users of the web at the time, that translates to a 6-minute loading time for just that GIF! Fortunately, it was never hosted online and was presented to the examiners directly from my local machine. Long story short… the examiners loved my little website and I got a double A* Art GCSE for my effort! So what's all this preamble leading too? Well, this is just a long-winded way to tell you (again) that I'm old… 😭 The late 90s web landscape There have been some things I've noticed while questioning candidates in interviews recently, many candidates don't have the faintest idea of some old methodologies used in the world of Frontend, especially during the "unstable" periods of the web like the late 90s and early 00s: first browser war (1995–2001): Internet Explorer vs Netscape Navigator. second browser war (2004–2017): Internet Explorer vs Firefox vs Google Chrome. Being a Frontend Developer in the late 90s was both fun in terms of innovation, but also exceedingly stressful due to the instability of the web platform! A prime example being cross-browser development. What worked in Netscape, often looked very broken in Internet Explorer (and vice versa)! And if you had clients who were looking for "pixel perfect" designs across all browsers, you were in for a bad time! Throughout this period, a plethora of methodologies, tools, and workarounds were developed to address deficiencies in the web platform. And that’s what the rest of this post will delve into. Buckle up folks, we are about to time travel to an era when the internet started with the screeching of dial-up noises and I still had brown hair! 3. The Early Web – Layout and Design Practices Photoshop PSDs as the “single source of truth” Using Adobe Photoshop Documents (PSD) as a single source of design truth was a very common practice in the early days of web design. This was particularly common when design and development teams were siloed. A designer would create a PSD file that was intended to be precisely what the website would look like in the browser. Issues There were no considerations made for page structure, behaviour, and interactions. These fixed layout PSD's encouraged bad practices like: Fixed page dimensions e.g. 1024px x 768px as a static canvas. 1:1 mapping of Photoshop file to web page, which was rarely achievable, especially given cross-browser inconsistencies with page rendering. Lack of fluid or responsive design. I realise responsive design wasn't "a thing" at this time, but could it have been adopted sooner if fixed-width PSD workflows hadn't ever taken hold? The technique was more suited to static layouts, like print design, rather than web design. There were issues tracking interaction states like anchors with hover, active, disabled, and focus. Dynamic content was difficult to visualise (e.g., the rendering of different lengths of text in the browser). Poor accessibility adaptations, (e.g., increased font sizes, high-contrast modes weren’t considered in the design files). The only way to solve many of these issues would be to create multiple PSD's to hold all these different design assumptions. And in doing so, file management and design revisions would quickly become impractical and prone to being incomplete or inconsistent. Broken team collaboration The use of PSD's as the single source of truth broke how teams could collaborate and innovate. This was because: Developers would often have to interpret or translate the PSD design manually without the help of designers (e.g. due to siloed teams and strict job roles). Changes in the design required round-trips to designers, rather than being evolved collaboratively in code. Small team bottlenecks were common e.g. all design or development decisions needed to go through individuals rather than a whole team. Files became outdated rapidly leading to teams working on outdated designs without realising it. Designers often came up with designs that simply couldn't be built with the web technologies that existed at the time, especially when their designs were expected to work across different browsers. Modern Alternatives I'd like to think that designers using Photoshop for modern web design is a thing of the past, given the vast number of tools and techniques that are way more suited to the job than Photoshop ever was. Modern teams typically use: Design tokens and internal component libraries as the "single source of truth". Figma or similar tools with structured, token-aware components. Living style guides and code-driven prototypes (e.g., Storybook). Clear handoffs between teams using tools like zeroheight, or integrated design-to-dev platforms. The advantage of using these modern collaboration tools enables design and development teams to share the same language and source of truth, rooted in reusable, well-tested, and accessible components. Photoshop PSDs Summary In the early days of my frontend career, slicing PSDs was second nature, but that workflow is now obsolete. Using Photoshop as a "single source of truth" leads to siloed teams, rigid layouts, and poor collaboration. It ignores responsiveness, accessibility, and the realities of modern web development. Today, tools like Figma, design systems, and component libraries enable faster, more inclusive, and collaborative workflows. If you’re still building from PSDs, it’s time to move on! As the web has evolved, it is imperative that we all do the same. Frame-Based Layouts The Frame-based layouts were introduced into browsers to solve a specific set of problems. These were: To allow static content like navigation menus to remain in place while only the main content of the page gets updated on navigation. To Reduce the amount of data transferred over the network, since only one part of the page would need to be loaded. This was important at the time as remember in the late 1990s and early 2000s, broadband for most people simply wasn't available. If you were very lucky (and had the money), you'd be able to get an Integrated Services Digital Network (ISDN) line installed in your home, but it was mostly online businesses that had the money (and justification) for this type of connection, even ISDN wasn’t particularly quick. Adjusted for inflation you'd be looking at £60 to £80 per month for a 0.128 Mbps connection! To simulate a more app-like experience before JavaScript (JS) and CSS became more standardised and mature. Example For those curious here's an simple example of an HTML page using frames: Simple Frame Example Notes: To use and you needed to use a specific HTML 4.01 Frameset DOCTYPE, in the index.html file. In my example, for a single HTML page you'd have to maintain 3 HTML files (index.html, menu.html, and content.html). Each frame was like a mini browser window that loaded its own HTML document. Problems Unfortunately, there were a number of major issues with Frame-Based Layouts: Terrible user experience: the use and navigation of frames was confusing for users, since you effectively had multiple browser panes in a single page. The URL bar would often remain static even as the content of the page changed. Poor Accessibility: Screen readers and other assistive technology struggled to navigate frames, making it incredibly difficult for users with disabilities to understand the page content and overall page structure. Limited Search Engine Optimisation (SEO) compatibility: Even Search engines of the day struggled to understand the index pages built within frames. This lead to poor visibility in search results, as crawlers frequently failed to understand the relationship between the different frames. Navigation and Browser Compatibility: Because the back and forward buttons did not consistently produce the desired results, frames disrupted the navigation history, making it difficult for users to find their way around. The fact that different browser vendors weren't aligned with how frames should work in browsers lead to cross-browser issues too. Bad for security: Frames allowed for security risks like clickjacking. This is where an attacker gets a user to interact with a page that contains malicious content without the user even realising. Modern browsers now include protections to stop these types of security issues. Modern Alternatives Modern CSS Layouts: Flexbox and Grid allow for responsive layouts without compromising navigation, accessibility, and SEO. Single Page Applications (SPAs): Frameworks like React, Angular, and Vue allow developers to load page content dynamically without the need for full-page reloads. Be careful though, these libraries come with their own inherent issues if not used correctly! Server-Side Rendering and Partial Updates: techniques like server-side includes, AJAX, or component-based rendering to update portions of a page efficiently. Frame-Based Summary As mentioned in the introduction at the start of this post, my first website was built using frames! I sincerely hope you never have to maintain a frame-based website! But given the enormity of the internet, it is almost certain websites exist somewhere out there, having been untouched for decades! If you do come across one remember to take a quick peek at the source code, it's like looking back in time! They once served a purpose in the early days of the web but are now considered obsolete. Their usage introduced more problems than they solved, and have been replaced with techniques that are more performant, accessible, and maintainable. Any modern website should be using semantic HTML, CSS-based layouts, and progressive enhancement. Table-Based Layouts In the late 1990s and early 2000s table-based layouts were a common technique for building a web page structure: A simple example of what this would look like is below: Table Layout Example
My Table-Based Web Page

Welcome

This layout uses an HTML table for structure, which was common before CSS-based layouts became standard.

Why was it used? At the time CSS and layout techniques were inconsistent and unstable across browsers. Developers looking for stability in cross-browser rendering turned to tables in order to do this. At the time, tables offered: Predictable cross-browser rendering Control over alignment, spacing, and sizing Ability to nest elements in a grid-like structure It was very common to see nested tables and transparent "spacer GIFs" in invisible table cells to control these layouts more precisely. You'd often find logo's, sidebars, navigations, footer, and content areas all laid out within a deeply nested HTML table in order to achieve the layout and design that was required. Why was it so bad? The first and hopefully most obvious point is that the
element was intended for the display of tabular data. The fact that it was used as a workaround for the lack of standardised layout techniques, shows the ingenuity of developers at the time. Unfortunately, the use of tables for layout came with many considerable downsides, these included: Semantics: As mentioned, tables should represent structured data, not layout. Misusing them confuses assistive technologies and harms accessibility. Maintainability: Table-based layouts are challenging to read, modify, or scale. Small changes often require restructuring entire layouts. Responsiveness: They are rigid and not suited to fluid or responsive design, that was to come a number of years later. Performance: They delay rendering because browsers need to calculate the entire table layout before painting it to the page. Is the technique still used? There are some areas where table-based layouts may still be seen: Legacy code bases that desperately need to be refactored, I can imagine there are many internal systems across the world where table-based layouts are still used. I’d imagine the conversation about modernising goes something like this… "If it still works, why change it?". Very short-sighted I know! Table-based layouts are still widely used in emails due to the very limited support for CSS in email clients. It's not always the lack of support, it's the fact that many clients simply strip out any CSS in the process of rendering the email HTML. To give you an example of how bad it still is, from Outlook 2007+, Microsoft switched to Microsoft Word as the HTML rendering engine! And it's still in use today with Outlook 365! I did my fair share of HTML emails as a Junior Frontend Developer, the internationalised versions were the worst! Using the same table-based layouts for 19+ languages is never going to work well, especially with languages like German with their huge word length! Sorry… rant over! They are often still used in PDF generation tools e.g. data-driven print views: invoices etc. Modern alternatives Modern CSS offers clean, semantic, and powerful layout tools, including: Flexbox: One-dimensional layouts (ideal for nav bars, toolbars, etc.) CSS Grid: Two-dimensional layouts (ideal for full-page layout and complex structures) Media Queries: Enable responsiveness across devices Container Queries (still an emerging technology): Context-aware layout changes. Table-Based Summary Table-based layouts are a throwback to a bygone era, thankfully! The years of building HTML emails has scared me for life! As they were developed during a period in which CSS was inadequate for the task. Developers had to get creative to wrestle with browser quirks, and tables were the go-to workaround. Thankfully, these days, we’ve moved on to semantic HTML and proper CSS that actually does what we need (for webpages anyway). It’s cleaner, more flexible, and maintainable, and way better for accessibility. Quirks Mode Layouts This topic is covered in more detail later in the blog post, but I’ll briefly mention it here for completeness. It's important to realise that Quirks Mode Layouts weren't only limited to Internet Explorer (IE). It originated with Internet Explorer, but it was not exclusive to IE. Not only that, it later became a cross-browser convention in order to preserve the compatibility with many web pages on the internet. As that's the primary rule to consider when rolling out any new technology changes on the web. Whatever you do, "don't break the web!". For example, if a vendor released a new browser feature that wasn't backwards compatible with earlier versions of web pages, then you have a major issue as you've just broken the web! I talk about XHTML 2.0 later in the post, as it is a prime example of a proposed technology that would have broken the web. This backwards compatibility was the sole purpose of Quirks mode. It gave modern browsers the ability to switch between: Quirks Mode: Mimic pre-standards behaviour. Used for old, non-compliant pages. Standards Mode: Adheres to modern web specifications (W3C and WHATWG standards). Almost Standards Mode: The same as Standards mode only with one exception, table cell line-height rendering. This was to preserve layouts that used inline images inside HTML tables. How were layouts triggered? The browser decided which layout mode to use from the list above purely from the DOCTYPE used on the page. For example: Trigger Quirks mode This DOCTYPE will trigger Quirks mode layout: It looks valid, but it is missing the system identifier (URL) therefore it is a malformed DOCTYPE so Quirks Mode is triggered. A valid DOCTYPE is given below for comparison: That missing URL in the DOCTYPE is vital. Quirks Mode would also be activated if a page did not have a DOCTYPE or was not identical to the valid DOCTYPE given above in any way. IE even had a really nasty habit of triggering Quirks Mode if any character was output in the page source before the DOCTYPE. This included invisible characters and new lines and line returns too! As you can imagine, it made debugging issues an absolute nightmare! Almost Standards Mode The following DOCTYPE's will trigger Almost Standards Mode: HTML 4.01 Transitional (with full system identifier): HTML 4.01 Frameset (with full system identifier): XHTML 1.0 Transitional: XHTML 1.0 Frameset Standards Mode And lastly and most importantly for modern web development. This is the DOCTYPE you should be using to trigger standards mode in all modern browsers: This simplified DOCTYPE was brought in as part of the HTML5 Specification after 6 years of standardisation (2008–2014). Why was this version created? As outlined in all the examples above, previous DOCTYPE versions were: Long Error-prone Required both a public and a system identifier Affected rendering modes (Quirks, Almost Standards, Standards) In order to solve these issues this the new DOCTYPE: does not reference a Document Type Definition (DTD) as (HTML5 no longer relies on SGML-based validation). only has a single purpose: to trigger Standards Mode in all modern browsers. Quirks Mode Summary As we have discussed above, Quirks Mode wasn't an IE exclusive layout mode. It was introduced into all browsers in order to "not break the web". To ensure your website uses Standards Mode, use: And remember it must be the first characters in the source code on the page! Iframe Embeds for Layouts or Content If you've already read the Frame-based Layouts section above, then this section will be very similar. Although both are now considered legacy techniques, they come with distinct differences. Frameset As I discussed earlier here's example code: The tag completely replaced the tag and allowed developers to split the browser window into multiple, scrollable, resizable sections. Each section () loaded a separate HTML document (as seen in the code above). This technique was intended to use them as a layout structure. e.g. different parts of the User interface (UI) came from different HTML documents. Navigation in one frame would control the content in another frame. Inline frames (Iframes) These were introduced later in the HTML 4.01 Transitional specification. Example You will immediately notice the difference, using an