JSON in Cloudflare Workers KV: put, get, TTL, Namespaces & Durable Objects

Last updated:

Cloudflare Workers KV stores JSON at the edge — globally distributed across Cloudflare's 300+ network locations for low-latency reads from any location. KV.put(key, JSON.stringify(value)) stores any serializable JavaScript object; KV.get(key, { type: "json" }) retrieves and parses it in one call. KV has eventual consistency — writes propagate to all edge nodes within approximately 60 seconds, so reads may return stale data immediately after a write. The value size limit is 25 MB; keys are capped at 512 bytes. KV.put(key, value, { expirationTtl: 3600 }) sets a 1-hour TTL for automatic expiry without a cleanup cron job — useful for session data, rate limiting counters, and cached API responses. For strongly consistent JSON storage requiring atomic operations, Cloudflare Durable Objects is the correct tool. This guide covers KV binding setup, JSON put/get patterns, TTL expiry, listing keys, bulk operations, Wrangler local development, and when to use Durable Objects instead of KV.

KV Binding Setup and JSON put/get Patterns

Before accessing KV in a Worker, declare the namespace binding in wrangler.toml. The binding name becomes a property on the env object passed to the Worker's fetch handler. KV stores strings, ArrayBuffers, and ReadableStreams — serialize JavaScript objects with JSON.stringify before calling put, and deserialize with JSON.parse after calling get. Keys are arbitrary strings up to 512 bytes; a common pattern is namespacing keys with a colon separator — "user:123", "session:abc", "config:global".

# wrangler.toml — declare the KV namespace binding
[[kv_namespaces]]
binding = "MY_KV"           # name used in Worker code as env.MY_KV
id = "abc123..."            # production namespace ID from Cloudflare dashboard
preview_id = "def456..."    # namespace used by wrangler dev --remote
// Worker entry point — TypeScript
interface Env {
  MY_KV: KVNamespace
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url)

    // ── PUT: store a JSON object ──────────────────────────────
    if (request.method === 'POST' && url.pathname === '/user') {
      const body = await request.json<{ id: number; name: string; role: string }>()
      const key  = `user:${body.id}`

      // KV stores strings — serialize with JSON.stringify
      await env.MY_KV.put(key, JSON.stringify(body))

      return Response.json({ ok: true, key })
    }

    // ── GET: retrieve a JSON object ───────────────────────────
    if (request.method === 'GET' && url.pathname.startsWith('/user/')) {
      const id  = url.pathname.split('/')[2]
      const key = `user:${id}`

      // Method 1: manual JSON.parse
      const raw  = await env.MY_KV.get(key)
      const user = raw ? JSON.parse(raw) : null

      // Method 2: type: "json" shorthand (equivalent to method 1)
      const userTyped = await env.MY_KV.get<{ id: number; name: string; role: string }>(
        key,
        { type: 'json' }
      )
      // userTyped is typed as { id: number; name: string; role: string } | null

      if (!userTyped) {
        return Response.json({ error: 'Not found' }, { status: 404 })
      }

      return Response.json(userTyped)
    }

    // ── DELETE: remove a key ──────────────────────────────────
    if (request.method === 'DELETE' && url.pathname.startsWith('/user/')) {
      const id  = url.pathname.split('/')[2]
      await env.MY_KV.delete(`user:${id}`)
      return Response.json({ ok: true })
    }

    return new Response('Not Found', { status: 404 })
  },
}

The KVNamespace TypeScript interface is provided by the @cloudflare/workers-types package — install it as a dev dependency and add "@cloudflare/workers-types" to compilerOptions.types in tsconfig.json. The env.MY_KV.get&lt;T&gt;(key, {'{ type: "json" }'}) overload returns T | null, giving full type inference without a manual cast. Passing an unknown key always returns null — never throws — so null-checking is the correct guard rather than try/catch.

KV.get with type:'json' for Automatic Parsing

The type: "json" option is a convenience wrapper that calls JSON.parse on the stored string before returning. It is exactly equivalent to JSON.parse(await KV.get(key)) but reduces boilerplate in TypeScript Workers where you want the return type to be inferred. There are four type options available: "text" (default string), "json" (parsed object), "arrayBuffer" (binary data), and "stream" (ReadableStream). Use "json" for structured data; use "arrayBuffer" for images, PDFs, or binary blobs stored alongside JSON metadata.

import type { Env } from './types'

// ── The four KV.get() type options ────────────────────────────
async function kvTypeExamples(env: Env) {
  // type: "text" (default) — returns string | null
  const raw: string | null = await env.MY_KV.get('config:theme')

  // type: "json" — returns T | null, calls JSON.parse internally
  const config = await env.MY_KV.get<{ theme: string; version: number }>(
    'config:global',
    { type: 'json' }
  )
  // config?.theme is typed as string
  // config?.version is typed as number

  // type: "arrayBuffer" — returns ArrayBuffer | null (binary data)
  const image: ArrayBuffer | null = await env.MY_KV.get('asset:logo', { type: 'arrayBuffer' })

  // type: "stream" — returns ReadableStream | null (streaming large values)
  const stream: ReadableStream | null = await env.MY_KV.get('large:file', { type: 'stream' })

  return { config, image }
}

// ── Metadata alongside JSON values ────────────────────────────
// KV.put accepts a metadata object (up to 1024 bytes) stored separately
// from the value — useful for timestamps, content-type, or version tags
async function putWithMetadata(env: Env) {
  const product = { id: 'p42', name: 'Widget Pro', price: 29.99, stock: 150 }

  await env.MY_KV.put(
    'product:p42',
    JSON.stringify(product),
    {
      metadata: {
        updatedAt: new Date().toISOString(),
        version: 3,
        contentType: 'application/json',
      },
    }
  )
}

// ── getWithMetadata: retrieve value + metadata in one call ─────
async function getWithMetadata(env: Env) {
  const result = await env.MY_KV.getWithMetadata<
    { id: string; name: string; price: number; stock: number },
    { updatedAt: string; version: number; contentType: string }
  >('product:p42', { type: 'json' })

  // result.value  — the parsed JSON object (or null)
  // result.metadata — the metadata object (or null)

  if (!result.value) return null

  console.log(`Product ${result.value.name} (v${result.metadata?.version})`)
  return result
}

Metadata is stored separately from the value and can be read without fetching the full value — useful when listing keys and checking freshness before deciding whether to fetch the full 25 MB value. Metadata is limited to 1024 bytes. Common metadata fields: updatedAt (ISO 8601 timestamp), version (integer counter for optimistic concurrency), contentType (MIME type for non-JSON values), and size (byte count of the serialized value).

TTL Expiry for Session Data and Cached JSON

KV's built-in TTL expiry removes the need for a separate cleanup cron job. Set expirationTtl (relative, in seconds from now) or expiration (absolute Unix timestamp in seconds). The minimum TTL is 60 seconds. Common patterns: session tokens with 15-minute TTL, cached API responses with 5-minute TTL, rate limiting counters with 60-second TTL, and feature flag snapshots with 5-minute TTL.

import type { Env } from './types'

// ── Session token storage (15-minute TTL) ─────────────────────
async function createSession(env: Env, userId: number, roles: string[]) {
  const sessionId = crypto.randomUUID()
  const session = {
    userId,
    roles,
    createdAt: Date.now(),
    csrfToken: crypto.randomUUID(),
  }

  await env.MY_KV.put(
    `session:${sessionId}`,
    JSON.stringify(session),
    { expirationTtl: 900 }  // 15 minutes — auto-deleted after expiry
  )

  return sessionId
}

async function getSession(env: Env, sessionId: string) {
  // Returns null if key doesn't exist OR if TTL has expired — identical behavior
  const session = await env.MY_KV.get<{
    userId: number
    roles: string[]
    createdAt: number
    csrfToken: string
  }>(`session:${sessionId}`, { type: 'json' })

  return session
}

// ── Cached API response (5-minute TTL) ────────────────────────
async function getCachedWeather(env: Env, city: string): Promise<object> {
  const cacheKey = `weather:${city.toLowerCase()}`

  // Try cache first
  const cached = await env.MY_KV.get<object>(cacheKey, { type: 'json' })
  if (cached) return cached

  // Miss — fetch from upstream and cache for 5 minutes
  const res  = await fetch(`https://api.weather.example.com/v1/${city}`)
  const data = await res.json()

  await env.MY_KV.put(cacheKey, JSON.stringify(data), { expirationTtl: 300 })
  return data
}

// ── Rate limiting counter (60-second rolling window) ──────────
async function checkRateLimit(
  env: Env,
  ip: string,
  maxRequests = 100
): Promise<{ allowed: boolean; remaining: number }> {
  const key    = `rate:${ip}:${Math.floor(Date.now() / 60_000)}`  // 1-minute window
  const stored = await env.MY_KV.get<{ count: number }>(key, { type: 'json' })
  const count  = (stored?.count ?? 0) + 1

  await env.MY_KV.put(key, JSON.stringify({ count }), { expirationTtl: 60 })

  return {
    allowed:   count <= maxRequests,
    remaining: Math.max(0, maxRequests - count),
  }
}

// ── Absolute expiration: expire at a specific timestamp ────────
async function storeEventConfig(env: Env, eventId: string, config: object, endTime: Date) {
  const expirationUnixSeconds = Math.floor(endTime.getTime() / 1000)

  await env.MY_KV.put(
    `event:${eventId}`,
    JSON.stringify(config),
    { expiration: expirationUnixSeconds }  // absolute Unix seconds
  )
}

KV TTL expiry is not instantaneous at the exact second — Cloudflare's garbage collection may delay actual deletion by a few seconds beyond the TTL. However, KV.get() returns null immediately once the TTL expires, even if the key has not been physically deleted yet. Do not rely on KV TTL for sub-60-second precision; the minimum TTL of 60 seconds enforces this boundary. For microsecond-precision time-based logic, use Durable Objects with its storage.setAlarm() API.

Listing and Bulk-Reading KV Keys

KV.list() returns keys with pagination — up to 1000 per page. Prefix filtering narrows results to a specific key namespace without scanning all keys. Use listing for cache invalidation, batch jobs, or administrative dashboards, but avoid it in the hot path of per-request logic. Fetching all values after listing requires one KV.get() per key — batch these with Promise.all for concurrent reads.

import type { Env } from './types'

// ── Basic listing with pagination ─────────────────────────────
async function listAllUserKeys(env: Env): Promise<string[]> {
  const keys: string[] = []
  let cursor: string | undefined

  do {
    const result = await env.MY_KV.list({
      prefix: 'user:',          // only keys starting with "user:"
      limit:  1000,             // max 1000 per page (also the default)
      cursor,                   // undefined on first call
    })

    for (const key of result.keys) {
      keys.push(key.name)
      // key.expiration — Unix timestamp (if TTL was set)
      // key.metadata   — stored metadata object (if set during put)
    }

    cursor = result.list_complete ? undefined : result.cursor
  } while (cursor !== undefined)

  return keys
}

// ── Bulk-read: list keys then fetch all values concurrently ───
async function getAllUsers(env: Env): Promise<object[]> {
  const keys   = await listAllUserKeys(env)
  const values = await Promise.all(
    keys.map((key) => env.MY_KV.get<object>(key, { type: 'json' }))
  )
  // Filter null values (keys deleted between list and get)
  return values.filter((v): v is object => v !== null)
}

// ── Cache invalidation: delete all keys matching a prefix ─────
async function invalidateProductCache(env: Env, productId: string): Promise<number> {
  const prefix = `product:${productId}:`
  let deleted = 0
  let cursor: string | undefined

  do {
    const result = await env.MY_KV.list({ prefix, cursor })

    await Promise.all(result.keys.map((k) => env.MY_KV.delete(k.name)))
    deleted += result.keys.length

    cursor = result.list_complete ? undefined : result.cursor
  } while (cursor !== undefined)

  return deleted
}

// ── Count keys with a prefix (without fetching values) ────────
async function countSessionKeys(env: Env): Promise<number> {
  let count = 0
  let cursor: string | undefined

  do {
    const result = await env.MY_KV.list({ prefix: 'session:', cursor, limit: 1000 })
    count += result.keys.length
    cursor = result.list_complete ? undefined : result.cursor
  } while (cursor !== undefined)

  return count
}

// ── Read metadata without fetching values ─────────────────────
async function getRecentlyCachedProducts(env: Env, since: Date): Promise<string[]> {
  const sinceMs = since.getTime()
  const recent: string[] = []
  let cursor: string | undefined

  do {
    const result = await env.MY_KV.list<{ updatedAt: string }>({
      prefix: 'product:',
      cursor,
    })

    for (const key of result.keys) {
      if (key.metadata?.updatedAt) {
        const updated = new Date(key.metadata.updatedAt).getTime()
        if (updated >= sinceMs) recent.push(key.name)
      }
    }

    cursor = result.list_complete ? undefined : result.cursor
  } while (cursor !== undefined)

  return recent
}

Promise.all on concurrent KV.get calls is safe in Workers — there is no practical concurrency limit per Worker invocation for KV reads. Avoid sequential await inside a loop when fetching many values; parallel reads reduce total latency proportionally to the number of keys. For namespaces with more than 100,000 keys, paginated listing is slow — consider maintaining a secondary index (a KV entry storing an array of keys) for faster lookup if your access pattern requires frequent enumeration.

KV in Hono and itty-router Workers

Hono is the most popular micro-framework for Cloudflare Workers — it provides typed context with KV bindings, Zod middleware for JSON validation, and a clean routing API. itty-router is a lighter alternative with minimal overhead. Both frameworks access KV through the same env pattern but reduce boilerplate for route-level JSON handling.

// Hono with KV JSON storage
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'

type Env = {
  Bindings: {
    MY_KV: KVNamespace
  }
}

const app = new Hono<Env>()

// ── Zod schema for request body validation ────────────────────
const UserSchema = z.object({
  id:    z.number().int().positive(),
  name:  z.string().min(1).max(100),
  email: z.string().email(),
  role:  z.enum(['admin', 'editor', 'viewer']),
})

type User = z.infer<typeof UserSchema>

// ── PUT /users/:id — store JSON in KV ─────────────────────────
app.put(
  '/users/:id',
  zValidator('json', UserSchema),
  async (c) => {
    const user = c.req.valid('json')  // typed as User after Zod validation
    const key  = `user:${c.req.param('id')}`

    await c.env.MY_KV.put(key, JSON.stringify(user), {
      metadata: { updatedAt: new Date().toISOString() },
    })

    return c.json({ ok: true, key }, 201)
  }
)

// ── GET /users/:id — retrieve and return JSON from KV ─────────
app.get('/users/:id', async (c) => {
  const key  = `user:${c.req.param('id')}`
  const user = await c.env.MY_KV.get<User>(key, { type: 'json' })

  if (!user) return c.json({ error: 'Not found' }, 404)
  return c.json(user)
})

// ── GET /users — list all users (paginated) ───────────────────
app.get('/users', async (c) => {
  const prefix = c.req.query('prefix') ?? 'user:'
  const result = await c.env.MY_KV.list({ prefix, limit: 100 })

  const users = await Promise.all(
    result.keys.map((k) => c.env.MY_KV.get<User>(k.name, { type: 'json' }))
  )

  return c.json({
    users:    users.filter((u): u is User => u !== null),
    total:    result.keys.length,
    complete: result.list_complete,
    cursor:   result.list_complete ? null : result.cursor,
  })
})

// ── DELETE /users/:id ─────────────────────────────────────────
app.delete('/users/:id', async (c) => {
  await c.env.MY_KV.delete(`user:${c.req.param('id')}`)
  return c.json({ ok: true })
})

export default app

Hono's zValidator('json', schema) middleware validates the request body before the route handler runs — invalid JSON or a body that fails the Zod schema returns a 400 response automatically. The typed context c.env.MY_KV gives full TypeScript inference including the KVNamespace methods. For itty-router, access KV through the env argument in the request handler: const user = await env.MY_KV.get&lt;User&gt;(key, {'{ type: "json" }'}).

Local Development with Wrangler and Miniflare

wrangler dev uses Miniflare to simulate KV locally — no Cloudflare account needed for basic development. Local KV state persists in .wrangler/state/v3/kv/ between wrangler dev restarts. Use the Wrangler CLI to seed local KV with test JSON before running the Worker, or use --remote to connect to a preview namespace in Cloudflare's infrastructure.

# ── wrangler.toml — full KV configuration ────────────────────
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2026-01-01"

[[kv_namespaces]]
binding     = "MY_KV"
id          = "abc123def456..."        # production namespace UUID
preview_id  = "789xyz..."             # used by wrangler dev --remote

# ── Local development commands ────────────────────────────────
# Start local dev server (uses Miniflare, no Cloudflare account needed)
# wrangler dev

# Start dev server connected to Cloudflare preview namespace
# wrangler dev --remote

# ── Seed local KV with test JSON ──────────────────────────────
# wrangler kv key put --binding MY_KV "user:1" #   '{"id":1,"name":"Alice","role":"admin"}' --local

# wrangler kv key put --binding MY_KV "user:2" #   '{"id":2,"name":"Bob","role":"editor"}' --local

# ── Read a local KV value ─────────────────────────────────────
# wrangler kv key get --binding MY_KV "user:1" --local

# ── List local KV keys by prefix ─────────────────────────────
# wrangler kv key list --binding MY_KV --prefix "user:" --local

# ── Delete a local KV key ─────────────────────────────────────
# wrangler kv key delete --binding MY_KV "user:1" --local

# ── Operate on production KV (no --local flag) ────────────────
# wrangler kv key list --binding MY_KV --prefix "session:"
# wrangler kv namespace list
// vitest.config.ts — unit testing Workers with Miniflare
import { defineConfig } from 'vitest/config'
import { unstable_dev } from 'wrangler'

// Integration test using wrangler's unstable_dev (Worker + real KV bindings)
// vitest + @cloudflare/vitest-pool-workers recommended for unit tests

// For unit tests, use @cloudflare/vitest-pool-workers:
// npm install --save-dev @cloudflare/vitest-pool-workers

// vitest.config.ts:
// import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'
// export default defineWorkersConfig({
//   test: {
//     poolOptions: {
//       workers: {
//         wrangler: { configPath: './wrangler.toml' },
//       },
//     },
//   },
// })

// Example test:
import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'
import { describe, it, expect, beforeEach } from 'vitest'
import worker from '../src/index'

describe('KV JSON storage', () => {
  beforeEach(async () => {
    // Seed test data — env.MY_KV is the real Miniflare KV binding in tests
    await env.MY_KV.put('user:1', JSON.stringify({ id: 1, name: 'Alice', role: 'admin' }))
  })

  it('returns stored user as JSON', async () => {
    const ctx  = createExecutionContext()
    const req  = new Request('http://localhost/users/1')
    const res  = await worker.fetch(req, env, ctx)
    await waitOnExecutionContext(ctx)

    expect(res.status).toBe(200)
    const body = await res.json<{ id: number; name: string }>()
    expect(body.name).toBe('Alice')
  })

  it('returns 404 for missing keys', async () => {
    const ctx = createExecutionContext()
    const req = new Request('http://localhost/users/999')
    const res = await worker.fetch(req, env, ctx)
    await waitOnExecutionContext(ctx)

    expect(res.status).toBe(404)
  })
})

@cloudflare/vitest-pool-workers runs tests inside a Miniflare Worker environment — the env import provides real KV bindings, not mocks. KV operations in tests are synchronous from the test's perspective (Miniflare removes the eventual consistency delay), which simplifies test assertions but means tests do not simulate the ~60-second propagation window of production. Test eventual-consistency edge cases manually using wrangler dev with two separate HTTP requests to different PoPs via a VPN.

KV vs Durable Objects: Choosing the Right JSON Storage

Cloudflare provides two primary persistence options for Workers: KV (distributed, eventually consistent, read-heavy) and Durable Objects (single-location, strongly consistent, write-heavy with real-time requirements). Choosing the wrong storage for the use case leads to subtle data bugs — stale reads from KV when strong consistency is needed, or unnecessary latency from Durable Objects when KV's global read performance is the priority.

// ── Durable Object: strongly consistent JSON storage ──────────
// Use when: atomic read-modify-write, real-time collaboration,
//           WebSocket connections, operations that must not race

export class SessionDO implements DurableObject {
  constructor(private readonly state: DurableObjectState) {}

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url)

    if (request.method === 'GET') {
      // Strong read: always returns the latest value — no staleness
      const session = await this.state.storage.get<object>('session')
      return session
        ? Response.json(session)
        : new Response('Not Found', { status: 404 })
    }

    if (request.method === 'PUT') {
      const body = await request.json()

      // Atomic write: no concurrent write can interleave
      await this.state.storage.put('session', body)
      return Response.json({ ok: true })
    }

    // Atomic increment — impossible to implement safely with KV
    if (request.method === 'POST' && url.pathname === '/increment') {
      const counter = ((await this.state.storage.get<number>('counter')) ?? 0) + 1
      await this.state.storage.put('counter', counter)
      return Response.json({ counter })
    }

    return new Response('Method Not Allowed', { status: 405 })
  }
}

// ── Decision matrix ────────────────────────────────────────────
// Feature              KV                    Durable Objects
// Consistency          Eventual (~60s lag)   Strong (immediate)
// Read latency         <5 ms (any PoP)       10-100 ms (one location)
// Write propagation    ~60 seconds           Immediate
// Atomic operations    No                    Yes (storage.transaction())
// Value size           Up to 25 MB           Up to 128 KB per key
// Keys per namespace   100 million           Unlimited
// Use cases            Cache, sessions,      Collaboration, counters,
//                      config, feature flags WebSocket state, inventory
// Pricing              Reads: $0.50/million  Requests: $0.15/million
//                      Writes: $5/million    Storage: $0.20/GB/month

// ── Worker routing to KV or DO based on consistency need ──────
import type { Env } from './types'

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url)

    // Cache reads → KV (globally fast, stale reads acceptable)
    if (url.pathname.startsWith('/cache/')) {
      const key   = url.pathname.slice(7)
      const value = await env.MY_KV.get<object>(key, { type: 'json' })
      return value
        ? Response.json(value)
        : new Response('Not Found', { status: 404 })
    }

    // Collaborative counters → Durable Object (strong consistency)
    if (url.pathname.startsWith('/counter/')) {
      const id     = url.pathname.split('/')[2]
      const doId   = env.SESSION_DO.idFromName(id)
      const stub   = env.SESSION_DO.get(doId)
      return stub.fetch(request)
    }

    return new Response('Not Found', { status: 404 })
  },
}

The key distinction: KV reads are served from the nearest Cloudflare PoP (under 5 ms latency worldwide) but may be up to 60 seconds stale. Durable Object reads and writes are routed to a single geographic location — typically near where the DO was first created — adding 10–100 ms of round-trip latency for users far from that location, but guaranteeing they always see the latest value. For most JSON storage use cases (configuration, session tokens, cached API responses, feature flags), KV's eventual consistency is acceptable and its global read performance is the right trade-off. Reserve Durable Objects for the small subset of use cases where stale reads would cause bugs, data loss, or security violations.

Key Terms

Cloudflare Workers KV
A globally distributed key-value store built into the Cloudflare Workers platform. KV stores string, ArrayBuffer, and ReadableStream values up to 25 MB per key, with keys up to 512 bytes. Values are replicated to Cloudflare's 300+ edge locations and served with under-5-ms read latency from anywhere in the world. KV is eventually consistent — writes propagate to all edge nodes within approximately 60 seconds. Accessed in Workers through a binding declared in wrangler.toml and exposed as a KVNamespace object on the env parameter of the Worker's fetch handler.
eventual consistency
A consistency model where writes are propagated to all replicas asynchronously — all replicas will eventually converge to the same value, but reads immediately after a write may return the old value from a replica that has not yet received the update. Cloudflare KV's eventual consistency window is approximately 60 seconds. Contrast with strong consistency, where reads always return the most recent write regardless of which replica handles the request. KV's eventual consistency enables its globally distributed read performance; Durable Objects provide strong consistency at the cost of routing all reads and writes to a single geographic location.
KV namespace
A logical container of key-value pairs in Cloudflare KV. Each namespace is a separate storage scope with its own key space — keys in one namespace are invisible to Workers bound to a different namespace. A single namespace supports up to 100 million key-value pairs. Workers access a namespace through a named binding (e.g., env.MY_KV) declared in wrangler.toml. Namespaces are created in the Cloudflare dashboard or with wrangler kv namespace create. Common practice: use separate namespaces for different data domains (sessions, cache, config) or separate environments (production, staging).
expirationTtl
A KV put option that sets a relative time-to-live in seconds from the current time. When the TTL elapses, the key returns null from KV.get() and Cloudflare eventually garbage-collects the physical entry. The minimum TTL is 60 seconds; values below this threshold cause a put error. Contrast with expiration, which accepts an absolute Unix timestamp in seconds rather than a relative duration. TTL-backed keys are commonly used for session tokens (900–3600 s), cached responses (60–3600 s), rate limiting counters (60 s), and short-lived feature flag snapshots (60–300 s).
Durable Object
A Cloudflare Workers primitive that provides strongly consistent, single-threaded storage and compute. Each Durable Object instance runs in a single Cloudflare location and handles requests sequentially — concurrent writes cannot interleave, eliminating race conditions on shared state. Durable Objects support atomic storage.transaction() operations, WebSocket hibernation for real-time connections, and storage.setAlarm() for scheduled callbacks. Storage is per-instance: each key-value pair is limited to 128 KB. Use Durable Objects when KV's eventual consistency is insufficient — multiplayer games, collaborative editing, shopping carts requiring atomic inventory updates, and real-time room state.
KV metadata
A separate JSON object of up to 1024 bytes stored alongside a KV value and returned by KV.list() without fetching the full value. Set via the metadata field in KV.put() options. Retrieved with KV.getWithMetadata(), which returns both the value and metadata in one call, or read from the key.metadata field in KV.list() results. Common uses: content-type hints, version numbers for optimistic concurrency, updatedAt timestamps for freshness checks, and byte-size estimates for pagination decisions.

FAQ

How do I store a JSON object in Cloudflare KV?

Call KV.put(key, JSON.stringify(value)) to serialize your JavaScript object to a string before storage. Cloudflare KV stores strings, ArrayBuffers, and ReadableStreams — it does not store native JavaScript objects directly. The key is a string of up to 512 bytes; the value can be up to 25 MB. For example: await env.MY_KV.put("user:123", JSON.stringify({ id: 123, name: "Alice", role: "admin" })). You can also store the result of JSON.stringify to a variable first for clarity. Because KV has eventual consistency, writes propagate to all edge nodes within approximately 60 seconds — reads in the same region return the new value faster, but a reader in a different Cloudflare PoP may see the old value for up to 60 seconds after the write.

How do I retrieve and parse JSON from Cloudflare KV?

There are two equivalent methods. The manual approach: const raw = await env.MY_KV.get("user:123"); const user = raw ? JSON.parse(raw) : null. The shorthand: const user = await env.MY_KV.get("user:123", { type: "json" }). The type: "json" option tells the KV runtime to call JSON.parse on the stored string automatically before returning — exactly equivalent to the manual two-step but reduces one line of code. Both return null if the key does not exist. When using type: "json", a null return means the key is absent; a JSON.parse failure throws a SyntaxError. Using TypeScript generics: await env.MY_KV.get<User>("user:123", { type: "json" }) returns User | null with full type inference.

How do I set a TTL (expiry) on a Cloudflare KV value?

Pass an options object as the third argument to KV.put(): await env.MY_KV.put("session:abc", JSON.stringify(sessionData), { expirationTtl: 3600 }). The expirationTtl field sets a relative TTL in seconds from the current time — 3600 means the key expires in 1 hour. Alternatively, expiration accepts an absolute Unix timestamp in seconds: { expiration: Math.floor(Date.now() / 1000) + 86400 } for a 24-hour TTL. Cloudflare KV automatically deletes expired keys with no cleanup job required. The minimum TTL is 60 seconds — values with expirationTtl less than 60 will error. Common TTL values: session tokens 900–3600 s, rate limiting counters 60 s, cached API responses 300–3600 s.

What is the maximum JSON size I can store in Cloudflare KV?

Each KV value can be up to 25 MB. Each key is limited to 512 bytes. A single KV namespace supports up to 100 million key-value pairs. The 25 MB limit applies to the serialized JSON string — a JavaScript object that serializes to 30 MB cannot be stored in a single KV entry. For values exceeding 25 MB, use Cloudflare R2 object storage and store only a reference URL or metadata object in KV. For structured JSON that requires partial updates or atomic operations on large documents, Cloudflare Durable Objects provides strongly consistent transactional storage without the 25 MB constraint. In practice, most KV JSON values for session data, configuration, and cached API responses are well under 1 MB, and the 25 MB limit is rarely a constraint.

Is Cloudflare KV consistent enough for session storage?

Cloudflare KV is eventually consistent — writes propagate to all edge nodes within approximately 60 seconds. For session storage where a user may hit different Cloudflare PoPs within seconds of a write (login, logout, role change), there is a window where one PoP returns the new session and another returns the old. For most session use cases — expiry TTL, user preferences, cached auth tokens — this 60-second window is acceptable if the system tolerates brief stale reads. Use short TTLs (900 seconds for session tokens), store session data that does not change frequently, and treat KV reads as "probably fresh within 60 seconds." For applications requiring immediate global consistency — real-time collaboration, financial transactions, inventory counts, or scenarios where a stale read causes a security violation — use Cloudflare Durable Objects instead.

How do I list all keys in a Cloudflare KV namespace?

Call KV.list() to retrieve keys: const result = await env.MY_KV.list(). The result has a keys array (each element has a name, optional expiration, and optional metadata), a list_complete boolean, and a cursor for pagination. When list_complete is false, call KV.list({ cursor: result.cursor }) to fetch the next page — up to 1000 keys per page by default. Filter by prefix: KV.list({ prefix: "user:" }) returns only keys starting with "user:". Listing all keys and fetching each value with a separate KV.get() is an O(N) operation — avoid in the hot path of requests. Use parallel Promise.all on the get calls to minimize total latency.

When should I use Durable Objects instead of KV for JSON?

Use Durable Objects when your JSON storage requires (1) strong consistency — reads always reflect the most recent write regardless of which Cloudflare PoP handles the request; (2) atomic operations — read-modify-write cycles that must not interleave with concurrent writes; (3) real-time collaboration — multiple clients updating shared state simultaneously; or (4) WebSocket connections with persistent server-side state. Concrete examples: a multiplayer game scoreboard (concurrent writes, race conditions matter), a shopping cart that must not lose items added from two devices simultaneously, and a rate limiter counter that must be exact rather than approximate. Use KV when your use case is read-heavy with infrequent writes, tolerates 60-second eventual consistency, and benefits from global low-latency reads — session cache, feature flags, configuration, and API response cache.

How do I test Cloudflare KV locally with Wrangler?

Run wrangler dev to start the local development server — Wrangler uses Miniflare under the hood to simulate KV bindings locally. KV data in local development is stored in .wrangler/state/v3/kv/. To pre-populate local KV with test data: wrangler kv key put --binding MY_KV "user:123" '{"id":123}' --local. To list local keys: wrangler kv key list --binding MY_KV --local. Add --remote to any Wrangler KV command to interact with production or preview namespaces instead of the local simulation. For unit tests, use @cloudflare/vitest-pool-workers — it runs tests inside a real Miniflare Worker environment where env.MY_KV is a live KV binding with synchronous (immediately consistent) behavior, unlike the eventual consistency of production.

Validate and format your KV JSON values

Paste any JSON object into Jsonic's formatter to validate, beautify, and inspect structure before storing it in Cloudflare KV.

Open JSON Formatter

Further reading and primary sources