Cloudflare Workers KV
Cloudflare Workers KV is a globally distributed key-value store that replicates data to hundreds of edge locations. Storing Messagevisor datafiles in KV lets your Worker serve them with sub-50ms latency worldwide, while giving you precise control over caching, headers, and routing logic.
GitHub Actions builds the datafiles and writes them to KV on every merge to main. Pull requests only validate - nothing reaches Cloudflare until a change is approved and merged.
How it works#
┌─────────────────────────────────┐│ Messagevisor repository ││ YAML definitions + tests │└───────────────┬─────────────────┘ │ push to main ▼┌─────────────────────────────────┐│ GitHub Actions ││ lint → test → build → upload │└───────────────┬─────────────────┘ │ wrangler kv key put ▼┌─────────────────────────────────┐│ Cloudflare Workers KV ││ messagevisor-web-en-US.json ││ messagevisor-web-nl-NL.json │└───────────────┬─────────────────┘ │ Worker reads KV on request ▼┌─────────────────────────────────┐│ Cloudflare Worker (edge) ││ serves datafiles with headers │└───────────────┬─────────────────┘ │ fetch on startup + poll ▼┌─────────────────────────────────┐│ Your application │└─────────────────────────────────┘When to choose KV over Static Assets#
Use Workers KV when you need:
- Programmatic uploads - your CI pipeline writes individual keys rather than deploying a directory
- Key-level control - invalidate or update a single datafile without redeploying anything
- Dynamic routing logic - the Worker can look up the right datafile based on request headers, query parameters, or geographic data
- Metadata alongside datafiles - store revision identifiers, deploy timestamps, or flags as separate keys
Use Workers Static Assets when you want the simplest possible setup with no Worker logic.
Prerequisites#
- A Cloudflare account
wranglerinstalled in your project (npm install --save-dev wrangler)- A Cloudflare API token with Workers and KV write permissions
- A KV namespace created in your Cloudflare dashboard (or via wrangler)
Creating a KV namespace#
Create the namespace once with wrangler:
$ npx wrangler kv namespace create DATAFILESWrangler prints the namespace ID. Copy it - you will need it in wrangler.toml.
For local development, create a preview namespace as well:
$ npx wrangler kv namespace create DATAFILES --previewWrangler configuration#
name = "messagevisor-datafiles"compatibility_date = "2024-01-01"main = "worker/index.js"[[kv_namespaces]]binding = "DATAFILES"id = "your-kv-namespace-id"preview_id = "your-preview-kv-namespace-id"Replace your-kv-namespace-id with the ID from the previous step.
Worker entry point#
The Worker reads the requested datafile from KV and returns it with appropriate headers:
export default { async fetch(request, env) { const url = new URL(request.url); // Strip leading slash to get the KV key // e.g. /messagevisor-web-en-US.json → messagevisor-web-en-US.json const key = url.pathname.replace(/^\//, ""); if (!key || key === "") { return new Response("Not found", { status: 404 }); } const value = await env.DATAFILES.get(key, { type: "text" }); if (value === null) { return new Response("Not found", { status: 404 }); } return new Response(value, { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", "Cache-Control": "public, max-age=300, s-maxage=300", }, }); },};Handling OPTIONS preflight#
If your applications run on different origins, add explicit OPTIONS handling:
export default { async fetch(request, env) { if (request.method === "OPTIONS") { return new Response(null, { status: 204, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Max-Age": "86400", }, }); } const url = new URL(request.url); const key = url.pathname.replace(/^\//, ""); if (!key) return new Response("Not found", { status: 404 }); const value = await env.DATAFILES.get(key, { type: "text" }); if (value === null) return new Response("Not found", { status: 404 }); return new Response(value, { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=300, s-maxage=300", }, }); },};GitHub Actions workflow#
The workflow has two jobs: validate runs on every pull request and push, deploy runs only on merges to main.
name: Messagevisoron: pull_request: push: branches: [main]jobs: validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx messagevisor lint - run: npx messagevisor test - run: npx messagevisor build --showSize deploy: runs-on: ubuntu-latest needs: validate if: github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx messagevisor build - name: Upload datafiles to KV run: | for file in datafiles/*.json; do key=$(basename "$file") echo "Uploading $key" npx wrangler kv key put \ --namespace-id="${{ secrets.CLOUDFLARE_KV_NAMESPACE_ID }}" \ "$key" \ --path="$file" done env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - name: Deploy Worker run: npx wrangler deploy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}CLOUDFLARE_API_TOKEN and CLOUDFLARE_KV_NAMESPACE_ID are repository secrets set in GitHub → Settings → Secrets and variables → Actions.
Uploading datafiles in parallel#
For projects with many datafiles, upload them in parallel to reduce deploy time:
- name: Upload datafiles to KV run: | pids=() for file in datafiles/*.json; do key=$(basename "$file") npx wrangler kv key put \ --namespace-id="${{ secrets.CLOUDFLARE_KV_NAMESPACE_ID }}" \ "$key" \ --path="$file" & pids+=($!) done for pid in "${pids[@]}"; do wait "$pid"; done env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}Sets-based deployment#
For projects using sets, build only the set being promoted and write its datafiles to KV:
deploy: runs-on: ubuntu-latest needs: validate if: github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx messagevisor build --set=production - name: Upload production datafiles to KV run: | for file in datafiles/*.json; do key=$(basename "$file") npx wrangler kv key put \ --namespace-id="${{ secrets.CLOUDFLARE_KV_NAMESPACE_ID }}" \ "$key" \ --path="$file" done env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - run: npx wrangler deploy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}If you maintain separate KV namespaces per environment (staging, production), use different secrets and namespace IDs for each.
Revision key#
Store the revision alongside datafiles so applications can check for updates without fetching the full payload. Write it as a separate KV key at the end of each deploy:
- name: Write revision key run: | echo "${{ github.sha }}" | npx wrangler kv key put \ --namespace-id="${{ secrets.CLOUDFLARE_KV_NAMESPACE_ID }}" \ "REVISION" \ --stdin env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}Update the Worker to expose it:
export default { async fetch(request, env) { const url = new URL(request.url); const key = url.pathname.replace(/^\//, ""); if (key === "revision") { const revision = await env.DATAFILES.get("REVISION", { type: "text" }); return new Response(revision ?? "unknown", { headers: { "Content-Type": "text/plain", "Access-Control-Allow-Origin": "*", "Cache-Control": "no-store", }, }); } if (!key) return new Response("Not found", { status: 404 }); const value = await env.DATAFILES.get(key, { type: "text" }); if (value === null) return new Response("Not found", { status: 404 }); return new Response(value, { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=300, s-maxage=300", }, }); },};Your application can poll /revision, compare with its last-known revision, and only fetch a full datafile when the value has changed.
Fetching datafiles at runtime#
import { createMessagevisor } from "@messagevisor/sdk";const datafile = await fetch( "https://messagevisor-datafiles.your-account.workers.dev/messagevisor-web-en-US.json").then((res) => res.json());const m = createMessagevisor({ datafile, locale: "en-US", context: { platform: "web" },});Or with a custom domain:
const datafile = await fetch( "https://translations.yoursite.com/messagevisor-web-en-US.json").then((res) => res.json());Custom domain#
To serve from a subdomain you own, add a route in wrangler.toml:
name = "messagevisor-datafiles"compatibility_date = "2024-01-01"main = "worker/index.js"routes = [ { pattern = "translations.yoursite.com/*", zone_name = "yoursite.com" }][[kv_namespaces]]binding = "DATAFILES"id = "your-kv-namespace-id"preview_id = "your-preview-kv-namespace-id"The zone must be on Cloudflare and the DNS record for translations.yoursite.com must point to Cloudflare.
KV consistency model#
KV is eventually consistent. After a write, edge nodes outside the region that accepted the write will see the new value within approximately 60 seconds under normal conditions. This is the primary operational difference from Workers Static Assets (which propagates within CDN cache TTL after first request).
For most translation update use cases, 60-second propagation is acceptable. If you need faster consistency guarantees globally, consider Workers Static Assets combined with a short cache TTL instead.
Listing available datafiles#
To expose a directory listing - useful for tooling that needs to discover which locales or targets are deployed - add an index key:
- name: Write index key run: | files=$(ls datafiles/*.json | xargs -I{} basename {} | tr '\n' ',' | sed 's/,$//') echo "[$( echo $files | sed "s/,/\",\"/g" | sed 's/^/\"/' | sed 's/$/\"/' )]" \ | npx wrangler kv key put \ --namespace-id="${{ secrets.CLOUDFLARE_KV_NAMESPACE_ID }}" \ "INDEX" \ --stdin env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}Then serve it from the Worker at /index:
if (key === "index") { const index = await env.DATAFILES.get("INDEX", { type: "text" }); return new Response(index ?? "[]", { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Cache-Control": "no-store", }, });}
