DEV Community

Cover image for Customizing Cache Keys on Cloudflare's Free Plan
dongnaebi
dongnaebi

Posted on

Customizing Cache Keys on Cloudflare's Free Plan

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): A multilingual emoji search engine supporting 30 languages, vibrant emojis make your articles and social posts more lively
  • Yesicon (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

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/. 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. In the Add Rule page, the top section is our rewrite condition - ours is complex so select Edit expression:

Untitled

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:

# 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" }  
)
Enter fullscreen mode Exit fullscreen mode

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

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

regex_replace(http.cookie, "^.*lang=([^;]+).*$|^.*$", "${1}") 
Enter fullscreen mode Exit fullscreen mode

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

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

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:

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

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:

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'
  }
})
Enter fullscreen mode Exit fullscreen mode

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

Untitled

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.

Top comments (0)