DEV Community

Gary Stupak
Gary Stupak

Posted on • Edited on • Originally published at edgekits.dev

Stop Redeploying to Update Translations: Granular Edge Cache Invalidation with Cloudflare Purge API

Edge-Native i18n architecture diagram showing global Cloudflare Workers network with decoupled TRANSLATION_UPDATE JSON deployment - the core concept of granular edge cache invalidation via Cloudflare Purge API for Astro i18n.

Edge-Native i18n with Astro & Cloudflare Workers - Part 3

In Part 1, I made a bold promise. Translations, I argued, are not code - they are data. Your Worker shouldn't care whether you support two languages or fifty. Adding a typo fix to a German translation shouldn't feel like shipping a software release.

I genuinely believed I had delivered on that promise. The architecture stored translations in Cloudflare KV, cached them at the edge, and invalidated stale entries via content-based hashing. TRANSLATIONS_VERSION - a SHA hash of the translation bundle - was baked into the Worker as a build-time constant and embedded into every cache key. Change a string, regenerate the hash, and all old cache entries became invisible. Clean, deterministic, content-driven.

Then I deployed the EdgeKits website to production and noticed something uncomfortable.

I wanted to tweak the hero heading on the Spanish landing page. But the only way to push that change was to run npm run i18n:migrate and redeploy the Worker. Because the hash constant lived inside the Worker bundle, updating the hash meant rebuilding the entire application - every time, for every translation change.

The architecture shipped translations as data. But it invalidated them as code.

This is the kind of coupling you only notice after you start living with a system. It's subtle. It works. It even works well. But it quietly contradicts the very philosophy the architecture was designed to embody.

Untangling Translations from Deployments: What We'll Build

In this article, I'll walk through how I untangled that coupling. We'll visit three intermediate architectures, each of which solved one problem while revealing the next.

We'll talk about why wrangler deploy --var isn't actually separate from a deployment. Why storing the version in KV creates a mandatory read on every request. Why caching that version with a short TTL scales poorly across Cloudflare's global edge.

And finally, why the right answer was to stop trying to be clever about cache keys - and start being explicit about cache invalidation.

By the end of this piece, we'll have an architecture where:

  • Updating a translation requires exactly one command: npm run i18n:migrate.
  • No Worker redeployment is triggered, ever.
  • The edge cache is invalidated surgically - only the namespaces that actually changed are purged, while the rest stay warm.
  • The hot path performs zero KV reads and a single cache lookup.

We'll get there by using a part of the Cloudflare platform that most developers associate with static assets, not with i18n: the Cache Purge API.

A note on the original architecture before we proceed. Part 1 and Part 2 describe a real, working system. If you've already built on it, you haven't built on a broken foundation - you've built on a simpler one with a narrower valid use case.

I kept the original implementation available as a separate branch (v1-version-based-cache) because it's still the right choice for certain projects: sites deployed on *.workers.dev subdomains (where Purge API isn't available), projects that don't want to manage API tokens, or solo builds where translation changes are rare. We'll revisit this trade-off explicitly at the end.

But for anything that ships to a custom domain through Cloudflare - and especially for any project where translations will be updated independently from code - the architecture in this article is what you actually want.

Let's start by looking at exactly where the original approach quietly breaks its own promise.

Anatomy of Translation-Deploy Coupling

Before we fix something, we need to look at it closely enough to see why it's broken. And the tricky part about the original TRANSLATIONS_VERSION approach is that on the surface, it looks like it solves exactly the problem we wanted to solve.

Let me walk through what the architecture actually does, step by step.

When you run npm run i18n:bundle, the build script reads every JSON file under ./locales/, computes a SHA hash of the entire collected payload, and writes that hash into a generated TypeScript file:

// src/domain/i18n/runtime-constants.ts

export const TRANSLATIONS_VERSION = '01b7fd54fe04'
Enter fullscreen mode Exit fullscreen mode

The fetchTranslations function then imports this constant at build time and embeds it into every cache key:

const cacheId = `${PROJECT.id}:i18n:v${TRANSLATIONS_VERSION}:${lang}:${namespaces.join(',')}`
Enter fullscreen mode Exit fullscreen mode

So a cached entry might look like edgekits.dev:i18n:v01b7fd54fe04:en:common,landing. The theory is clean: change a translation, regenerate the hash, and all old cache entries become addressed by a stale key that nothing will ever ask for again. Orphaned, sure - but invisible. Cloudflare's LRU (Least Recently Used - a cache management algorithm) eviction will clean them up eventually.

Read the rest on edgekits.dev

Everything up to this point is the setup - the architecture from Part 1 and the exact point where it stops keeping its promise. What follows is the teardown itself: the two failed attempts, the working design, the production logs, and the full implementation.

I published the complete version on my own site, and it lives there in one piece:

Stop Redeploying to Update Translations: Granular Edge Cache Invalidation with Cloudflare Purge API

Here is what the rest of the article covers:

  • Why TRANSLATIONS_VERSION cannot change without a redeploy. A step-by-step trace of what actually happens when you edit a JSON file, push it to KV, and nothing changes at the edge - plus the mental-model gap that makes the bug so easy to miss.

  • The first fix that looks correct and isn't. Passing the version through wrangler deploy --var, and how wrangler.jsonc quietly overwrites CLI variables.

  • Moving the version into KV. The hot-path regression it introduced, the double cache lookup, and the free-tier read math that killed the approach.

  • The design that worked: static cache keys plus explicit invalidation. The per-namespace key shape, and what the hot path looks like once the version is gone from the key entirely.

  • Cloudflare Purge API mechanics. Purge-by-URL, rate limits, the proxied-domain requirement that rules out *.workers.dev, and the API token and Zone ID setup.

  • Incremental purging with a hash file. Why "purge everything" causes a cache stampede, what counts as "changed", and the full i18n:migrate invalidation pipeline.

  • The implementation walkthrough. The keys module, the fetcher, the migration script, and the end-to-end flow from migration to a warm edge cache.

  • Production setup in six steps. KV namespace, Zone ID, API token, deploy, token as a Worker secret, first migration - with the DX considerations for translation workflows.

  • Real wrangler tail logs from production. Zero KV reads on the steady-state hot path, first-touch warming per edge node, mixed traffic with partial cache hits, one URL purged in isolation, and a cost table per operation.

  • Trade-offs across six axes, including when the Part 1 content-hash architecture is still the better call - it stays available as a separate branch for exactly those cases.

  • The conclusion: what "translations are data, not code" actually means once you apply it to feature flags, pricing config, and everything else that ships as code by habit.

Both architectures are open source. The code, the logs, and the full write-up are all in the article on edgekits.dev.

Top comments (0)