Skip to Content

Cloudflare KV

Keep the datafile in Workers KV and read it from a binding, so your Worker renders without fetching Improve. A cron Worker keeps the copy fresh.

4 min read

Workers KV is the closest thing Cloudflare has to an edge config: a key you read through a binding, in the same location as the Worker that reads it. Put the datafile there and a page render neither requests Improve nor waits on it; a small scheduled Worker keeps the copy current.

This page is the setup. The trade-offs, the freshness window and the request math are on Keep the datafile at your edge.

What it costs

At the default five-minute poll, the sync makes 8,640 datafile requests a month per environment, in place of one per page view. On your side that is one scheduled invocation per tick and one KV read per render.

1. Create the namespace

npx wrangler kv namespace create IMPROVE_CONFIG

Copy the id it prints into the Worker's configuration.

2. The sync Worker

wrangler.jsonc
{
	"name": "improve-datafile-sync",
	"main": "src/index.ts",
	"compatibility_date": "2026-11-01",
	"kv_namespaces": [{ "binding": "IMPROVE_CONFIG", "id": "<NAMESPACE_ID>" }],
	"triggers": { "crons": ["*/5 * * * *"] },
	"vars": { "ORGANIZATION_ID": "org_…" },
}
src/index.ts
interface Env {
	IMPROVE_CONFIG: KVNamespace
	ORGANIZATION_ID: string
}

// Every environment you sync costs one datafile request per tick. Delete the
// ones this site doesn't use.
const ENVIRONMENTS = ['production', 'staging', 'develop'] as const

const keyFor = (organizationId: string, environment: string) =>
	`improve/${organizationId}/${environment}`

const refresh = async (env: Env) => {
	await Promise.all(
		ENVIRONMENTS.map(async (environment) => {
			const response = await fetch(
				`https://improve.obelism.studio/config/${env.ORGANIZATION_ID}/${environment}`,
				{ headers: { 'Api-Version': '1' } },
			)
			if (!response.ok) {
				// Keep the copy already in KV: a bad poll is not a reason to lose it.
				console.error(`Improve ${environment}: ${response.status}`)
				return
			}

			const body = await response.text()
			const current = await env.IMPROVE_CONFIG.get(
				keyFor(env.ORGANIZATION_ID, environment),
			)
			if (body === current) return
			await env.IMPROVE_CONFIG.put(
				keyFor(env.ORGANIZATION_ID, environment),
				body,
			)
		}),
	)
}

export default {
	async scheduled(
		_controller: ScheduledController,
		env: Env,
		ctx: ExecutionContext,
	) {
		ctx.waitUntil(refresh(env))
	},
} satisfies ExportedHandler<Env>

Each tick reads the three datafiles, compares them with what is stored and writes only what changed. The writes run in waitUntil, so the cron invocation doesn't wait for them; a failed tick is retried by the next one.

3. Deploy and fill it

npx wrangler deploy
npx wrangler kv key get "improve/org_…/production" --binding IMPROVE_CONFIG --remote

The first cron fires within five minutes. Until then the key is empty, so either deploy early or write the first copy from the API:

curl -s -o datafile.json "https://improve.obelism.studio/config/org_…/production"
npx wrangler kv key put "improve/org_…/production" --path datafile.json --binding IMPROVE_CONFIG --remote

To test the handler itself, run it locally and fire the schedule:

npx wrangler dev --test-scheduled
curl "http://localhost:8787/__scheduled?cron=*+*+*+*+*"

That writes to the local copy in .wrangler/state, not to the deployed namespace.

4. Read it in your app

In the Worker that renders your pages:

src/index.ts
import { ImproveServerSDK } from '@obelism/improve-sdk/server'
import type { ImproveConfiguration } from '@obelism/improve-sdk/types'

interface Env {
	IMPROVE_CONFIG: KVNamespace
	ORGANIZATION_ID: string
}

export default {
	async fetch(request: Request, env: Env) {
		const config = (await env.IMPROVE_CONFIG.get(
			`improve/${env.ORGANIZATION_ID}/production`,
			'json',
		)) as ImproveConfiguration | null

		if (!config) return new Response('Datafile not synced yet', { status: 503 })

		const improveSdk = new ImproveServerSDK({
			organizationId: env.ORGANIZATION_ID,
			environment: 'production',
			config,
		})

		// getTestValue, getFlagValue and the rest work as they do today.
	},
}

Constructing the SDK from the copy costs one KV read, and the config is small. To avoid even that, keep the SDK per isolate and call improveSdk.loadConfig(config) when you refresh it.

If you would rather serve than answer 503 before the first tick, construct with a token instead of config and call fetchConfig(): the token is an environment API token (the same one a server SDK uses today, from Settings → Implementation).

organizationId loads the organization's first Space, which is what the SDK sends by default. If this organization has more than one Space, put the Space's id (space_…) in the datafile URL and pass it as spaceId instead.

What it costs

  • Improve: 8,640 datafile requests a month per environment at five minutes, against one per page view without the copy — 25,920 for the three environments the example syncs. All environments draw on the organization's one allowance.
  • Workers: one scheduled invocation per tick, and three subrequests inside it.
  • KV: one read per render and one write per change. Cloudflare's free plan includes 100,000 reads and 1,000 writes a day at the time of writing; skip the write when nothing changed, or three environments at five minutes alone use 864 of those writes. See KV pricing for the current rates.

Notes

  • KV is eventually consistent. A write is usually visible everywhere within 60 seconds, on top of the poll interval and Improve's edge window. A rolled-back flag reaches every visitor in minutes, not seconds.
  • Only active is copied. Request /config/{id}/{environment}/draft when you want to preview a draft; it stays where it is.
  • A deleted test or flag disappears on the next write, because the new datafile simply doesn't contain it. Nothing needs deleting from KV.

On this page

Marks in the margin

  • Note
  • Watch out
  • Updated

Scribes marked doubtful lines in the margin. That practice is called obelism.