Customizing Cache Keys on Cloudflare's Free Plan
Tags: webdev, cloudflare, cdn, javascript
Published: 2023-12-18 01:45:15

Summary: I built a multilingual emoji search engine that can detect a user's language and automatically redirect them to the corresponding language page. To do this, I needed to customize the cache key but Cloudflare requires upgrading to an Enterprise plan to customize cache keys which was beyond my budget. After thorough research of their documentation, I successfully implemented the equivalent of a customized cache key using the Transform Rules configuration. In this article, I will share how I set this up. Even if you don't need to customize cache keys, the first half of this article can teach you some CDN and caching concepts.  

Let me first introduce my websites to help you better understand the context:

- 🧐 **SearchEmoji** ([https://searchemoji.app](https://searchemoji.app/)): A multilingual emoji search engine supporting 30 languages, vibrant emojis make your articles and social posts more lively
- ✊ **Yesicon** ([https://yesicon.app](https://yesicon.app/)): A vector icon search engine supporting 8 languages, with over 200,000 high quality icons indexed, a ⌘CV helper for developers and designers

Now let's get started! First I'll explain why we need to customize the cache key:

Multilingual websites typically have the ability to detect the user's language and return the page in that language. The detection logic checks if the user has explicitly chosen a language before, which gets encoded and stored in a `lang` cookie. On the server side, we first check the `lang` cookie, and if empty fall back to the `Accept-Language` header to determine the user's language.  

This works fine when the user connects directly to the origin server, but to improve worldwide access speed sites typically use a CDN to cache and distribute content. Now imagine: a Chinese user visits the homepage so the Chinese version gets cached on the edge server with cache key of "/", then later an English user visits the homepage. Since there is a cached page for key "/", the edge server directly returns the cached Chinese page instead of fetching the English version from origin, leading to a poor user experience.

The solution is simple - all CDN providers allow customizing the cache key, so we can append the `lang` cookie and `Accept-Language` header. For example instead of key "/", we use "/?lang=en&acceptLanguage=en-US,en". Now each language variation has a separate cache entry and avoids mixing languages.  

The catch is Cloudflare requires upgrading to Enterprise plans for custom cache keys. This was my first time using Cloudflare as my other site Yesicon uses AWS Cloudfront CDN, which has no restrictions for cache keys and I easily configured custom keys there. I even considered migrating SearchEmoji to Cloudfront too but realized even their free tier wouldn't meet Yesicon's needs due to its popularity. So I decided to thoroughly research if I could achieve customized cache keys on Cloudflare without needing to upgrade!

After 3 late nights of poring through Cloudflare docs and experimenting, I finally figured it out! The key is understanding this flow:

![Untitled](https://img.searchemoji.app/acticle-image/Untitled.png)

When a request enters the edge server, the URL gets rewritten first according to our rewrite rules before cache lookup. So we just need to rewrite the URL to include the `lang` cookie and `Accept-Language` header values. Here's how I simulated custom cache keys:  

Let's walk through a user accessing [https://searchemoji.app/](https://searchemoji.app/). When it hits the edge server, we rewrite the URL to `https://searchemoji.app/?acceptLanguage=en-US,en`. The first English user causes the English page to be cached with this URL as key. Now other English users will hit this cached page as they share the same rewritten URL. But Chinese users would get rewritten to `https://searchemoji.app/?acceptLanguage=zh-CN,zh,en` so they will never see the English page. And if a user explicitly changes language, we just add the `lang` cookie to the query string.

But rewriting the URL dynamically isn't trivial - you need to understand [Cloudflare's Rules language](https://developers.cloudflare.com/ruleset-engine/rules-language/). In the Add Rule page, the top section is our rewrite condition - ours is complex so select `Edit expression`:

![Untitled](https://img.searchemoji.app/acticle-image/Untitled%201.png)

We only want to apply this logic on the homepage. And we should only rewrite if the user's language is in our supported list, since those will properly match a language page. For English and other unsupported languages we don't rewrite since English is the default and unsupported languages also fallback to English. So here is the complete conditional check:

```bash
# is home page 
http.request.uri.path eq "/" and
# user did not actively select English 
not (http.cookie contains "lang=en") and
( 
	# user actively selected supported non-English language  
	http.cookie contains "lang=" or  
	# browser language is a supported language 
	substring(http.request.accepted_languages[0], 0, 2) in  
		{ "zh" "es" "de" "ja" "fr" "ko" "pt" "ru" "tr" "ar" "it" "hi" "pl" "bn" "nl" "uk" "id" "ms" "vi" "th" "sv" "el" "he" "fi" "no" "da" "ro" "hu" }  
)
```

I added newlines and comments to help readability - remove those if copying the code.

Now we need to actually rewrite the URL query string using dynamic variables:  

![Untitled](https://img.searchemoji.app/acticle-image/Untitled%202.png)

To extract the `lang` value from the cookie, we can use a regex:

```bash
regex_replace(http.cookie, "^.*lang=([^;]+).*$|^.*$", "${1}") 
```

To simplify later processing, we concatenate the `lang` and `Accept-Language` into one parameter. Here is the full rewrite rule:  

```bash 
concat(
	# query key  
	"cfcache=",   
	# lang  
	regex_replace(http.cookie, "^.*lang=([^;]+).*$|^.*$", "${1}"),
	# Accept-Language  
	http.request.accepted_languages[0]  
)
```

So a Spanish user would get rewritten to `https://searchemoji.app/?cfcache=es-ES`, and if they explicitly switch language to English it would be `https://searchemoji.app/?cfcache=enes-ES`. A delimiter between the languages could improve readability.  

This works to add the parameter but has two issues:

- If there are existing parameters, they get overridden resulting in lost parameters. For example visiting `https://searchemoji.app/?v=1` gets rewritten losing the v=1 parameter to just `https://searchemoji.app/?cfcache=es-ES`.

- The added query parameter also persists after redirects. For example visiting `https://searchemoji.app/` redirects to `https://searchemoji.app/?cfcache=es-ES`, exposing the unsightly parameter to users.

You probably realized the first solution - we need to preserve any existing parameters:  

```bash
concat(  
	# query key   
	"cfcache=",    
	# lang   
	regex_replace(http.cookie, "^.*lang=([^;]+).*$|^.*$", "${1}"),  
	# Accept-Language   
	http.request.accepted_languages[0],
	# origin query  
	"&",  
	http.request.uri.query  
)
```

But this is still not perfect - if the original URL has no query parameters, it will end with a trailing & symbol `https://searchemoji.app/?cfcache=es-ES&`. Let's fix this and the second issue together:

The `?cfcache=xxx` query parameter added by edge servers is purely for caching, but edge servers forward requests to origin with this parameter attached. And when origin handles multilanguage redirect logic, it also appends the parameter. Origin implements redirects by returning 302 status codes, so we need to strip the parameter in the `Location` response header. I'm using `Nuxt 3` - here is the full handling logic:

```js
nitroApp.hooks.hook('render:response', (response, { event }) => {
  if (response.headers?.location?.includes('cfcache=')) {
    const host = 'https://searchemoji.app'
    const url = new URL(response.headers.location, host)
	// If there is & at the end of the parameter, it will be processed into { cfcache: 'es-ES&' }
    const params = new URLSearchParams(url.search)
	// So we delete this parameter, & will also be deleted
    params.delete('cfcache')
    url.search = params.toString()
    response.headers.location = url.toString().replace(host, '')
	// Let edge servers cache 302 status
    response.headers['Cache-Control'] = 'max-age=86400, must-revalidate'
  }
})
```

Now we have perfectly simulated custom cache keys with a bit of extra work! Here is the final configuration:  

![Untitled](https://img.searchemoji.app/acticle-image/Untitled%203.png)

I still think Cloudflare should provide custom cache keys for all plans, perhaps for an additional cost.  

Finally, I'd like to invite you to try my two sites (links at top) - whether you are a designer, developer, or creator emojis and icons can enhance your work. As the year draws to an end, adding some icons can spruce up your year-end review PowerPoint presentation and improve its aesthetics too! Feedback options are in the top right corner of the sites or comment below.