Storing data in KV

Every worker has key–value storage in env.KV. You open a namespace for a scope, such as a user, a domain or a route, and then read and write its keys with get(), put(), delete() and list(). On RTEdge.net the data lives in Cloudflare KV. In the playground the same API stores data in your browser, so you can build and test the whole feature before you deploy.

Note: Playground data and deployed data are separate stores that never sync. A value plus its metadata may be up to 64 KiB in the playground and up to 10 MiB on RTEdge.net.

Opening a namespace

env.KV has one member, open(scope, depth). It returns the namespace at once, so call it without await and then await the methods of the namespace.

Warning: Never await a namespace, return it from an async function, or put it in Promise.all(). A namespace answers every property, including then, so await env.KV.open(…) never settles.

The six scopes

The keys of the scope object you pass decide which data you see. Scopes are isolated from each other: for example, a UG namespace cannot see the keys of OG.

ScopeOpen withHolds data that is…
UG, user · globalKV.open({ user })kept for one user, across all routes and domains of the owner.
UD, user · domainKV.open({ user, domain })kept for one user on one domain.
UR, user · routeKV.open({ user, route })kept for one user in one route.
OG, globalKV.open()shared by every visitor, across all routes and domains of the owner.
OD, domainKV.open({ domain })shared by every visitor of one domain.
OR, routeKV.open({ route })shared by every visitor of one route.

The owner is the account that owns the deployment. Take user, domain and route from request.rt: on RTEdge.net, domain is name for a name.rt.ht deployment and the full hostname for a custom domain, and route is the domain plus the deployment path, such as name/docs.

Rules for the scope object

  • Only the keys user, domain and route are allowed, and they must come in that order. Another key throws Error: Invalid keys: …, and a wrong order throws Error: Invalid key order: …; expected order: user, domain, route.
  • When route is present, domain is ignored, and it is deleted from the object you passed.
  • depth Experimental is 0 or a negative integer. open({ user, route }, -1) lifts the last scope value out of the stored prefix, so you write it into the key yourself, joined with ● (U+25CF), and list() names include it. A positive depth acts as 0.

Warning: A falsy user, such as null for a visitor who is not signed in, counts as absent. KV.open({ user: null }) silently opens the scope that all visitors share: OG, OD or OR. Check request.rt.user before you open per-user storage.

Who can read what

Scopes organize your data; they are not an access-control system. The owner of a deployment is fixed at deploy time, so all of an owner's routes and domains share one keyspace. A worker opens any scope simply by passing its values, and nothing ties domain or route to the deployment that is calling. The platform also does not check that user is the signed-in visitor: pass request.rt.user yourself, and never a value taken from the request.

Reading and writing

The namespace methods are described in KVNamespace. In short:

A per-user counter

The worker counts the visits of each signed-in user. It opens the namespace without await, answers 401 when nobody is signed in, and stores the count as a string. Add route to the scope to count the visits per route.

html
<iframe src=:visits></iframe>
<script type=worker edge>
  export default {
    fetch: async (request, { KV }) => {
      const { user } = request.rt
      if (!user) return new Response('Sign in to count your visits', { status: 401 })
      const kv = KV.open({ user })
      const visits = (await kv.get('visits', 'json') ?? 0) + 1
      await kv.put('visits', JSON.stringify(visits))
      return Response.json({ user, visits })
    }
  }
</script>

In the playground you are the signed-in user, so you see {"user":"you@example.com","visits":1} and a higher number after each reload. Without an RTCode.io session you get the 401 text.

A counter shared by all visitors

With { route }, every visitor of the route reads and writes the same key. KV has no atomic increments, so two visits at the same moment can overwrite each other. Use it for numbers that may be approximate. The key has a prefix, because every playground in a browser shares the route scope; see KV in the playground.

html
<iframe src=:hits></iframe>
<script type=worker edge>
  export default {
    fetch: async (request, { KV }) => {
      const kv = KV.open({ route: request.rt.route })
      const hits = (await kv.get('example-hits', 'json') ?? 0) + 1
      await kv.put('example-hits', JSON.stringify(hits))
      return Response.json({ route: request.rt.route, hits })
    }
  }
</script>

Limits

PlaygroundRTEdge.net
Where the data livesIndexedDB in your browser, per output origin.Cloudflare KV.
Value plus metadata64 KiB (65,536 bytes).10 MiB (10,485,760 bytes).
MetadataCounts toward the 64 KiB.At most 1024 bytes of serialized JSON.
KeysNot empty, not . or ... The stored key, with a hidden scope prefix, has to fit in 512 bytes, so keep keys short.
ExpiryAny expirationTtl. Expired keys are deleted on the next read.At least 60 seconds.
list()Narrow the prefix to reach more keys.Up to 1000 keys per page, with cursor.
Writes and operationsNone documented.One write per second per key, and at most 1000 KV operations per invocation. A put() or delete() counts as a read plus a write.

The playground rejects a put() over the limit with Error: KV storage limits exceeded and writes nothing. RTEdge.net throws the same error above 10 MiB. Plan for the 64 KiB cap while you develop, and expect the larger one after deploying.

Consistency

On RTEdge.net, KV is eventually consistent. A write is usually visible at once in the same Cloudflare location, but it may take 60 seconds or more to appear elsewhere, and a lookup of a missing key can be cached too. There are no transactions and no atomic increments, so a read-modify-write counter loses updates when requests overlap. Design for the last writer winning. The playground has none of these delays, which is one more reason to test again after you deploy.

KV in the playground

Viewing stored data Experimental

On a deployment, the ::kv URL suffix opens a read-only viewer of the stored scopes of a route, for example https://name.rt.ht/::kv. Sign-in is required. The owner of the route sees all six scopes, and other signed-in users see their own UG, UD and UR scopes, keyed by their email. The viewer is not available in the playground.

Note: The ::kv viewer is experimental and may change.

Older deployments

A worker keeps the behaviour of the wrapper it was deployed with. Before shell 2025-09-29 the limit per key was 64 KiB, and env.KV.open() dates from shell 2024-10-30. Redeploy to get the current behaviour. See Shell versions.

See also