# Cloudflare KV (/docs/integrations/cloudflare-kv)

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](/docs/guides/datafile-at-the-edge).

## What it costs [#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 [#1-create-the-namespace]

```sh
npx wrangler kv namespace create IMPROVE_CONFIG
```

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

## 2. The sync Worker [#2-the-sync-worker]

```jsonc title="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_…" },
}
```

```ts title="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 [#3-deploy-and-fill-it]

```sh
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:

```sh
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:

```sh
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 [#4-read-it-in-your-app]

In the Worker that renders your pages:

```ts title="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 [#what-it-costs-1]

- **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](https://developers.cloudflare.com/kv/platform/pricing/) for the current rates.

## Notes [#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.
