JSON in Cloudflare Workers
Last updated:
Cloudflare Workers handle JSON through the standard Web API — Response.json(), request.json(), and full JSON.parse/JSON.stringify support — with no Node.js polyfills needed. Workers run on V8 isolates deployed across 300+ edge locations, so JSON parsing performance is identical to Node.js at sub-millisecond latency. This guide covers every layer: fetch handler JSON responses, parsing request bodies, KV storage, D1 SQL queries, Hono framework routes, Zod validation, and error handling.
JSON Responses in Fetch Handler
The simplest way to return JSON from a Cloudflare Worker is Response.json(data). This static constructor serializes data with JSON.stringify and sets Content-Type: application/json; charset=UTF-8 automatically. Pass an optional init object as the second argument to control status codes and headers.
// wrangler.toml: name = "my-worker", compatibility_date = "2024-01-01"
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
// Basic JSON response — sets Content-Type automatically
if (url.pathname === '/api/ping') {
return Response.json({ ok: true, ts: Date.now() })
}
// Custom status code
if (url.pathname === '/api/created') {
return Response.json({ id: 42, name: 'item' }, { status: 201 })
}
// Custom headers alongside JSON
if (url.pathname === '/api/cached') {
return Response.json(
{ data: 'hello' },
{
status: 200,
headers: {
'Cache-Control': 'public, max-age=60',
'X-Worker-Region': request.cf?.colo ?? 'unknown',
},
}
)
}
return Response.json({ error: 'Not Found' }, { status: 404 })
},
}Avoid constructing responses manually with new Response(JSON.stringify(data)) — you must set Content-Type yourself and it is easy to forget. Response.json() has been available in Cloudflare Workers since mid-2023 and is part of the WinterCG fetch standard.
| Method | Sets Content-Type | Use when |
|---|---|---|
Response.json(data) | Yes (automatic) | All JSON responses — recommended |
new Response(JSON.stringify(data), { headers } ) | Only if you add it manually | When you need full header control |
c.json(data) (Hono) | Yes (automatic) | Hono framework routes |
Parsing JSON Request Bodies
Call await request.json() to parse an incoming JSON body. The method reads the body stream and calls JSON.parse internally. A body stream can only be consumed once — call request.clone().json() if you need to read the body twice (for example, in middleware and in the handler).
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') {
return Response.json({ error: 'Method Not Allowed' }, { status: 405 })
}
// Check Content-Type before attempting to parse
const contentType = request.headers.get('Content-Type') ?? ''
if (!contentType.includes('application/json')) {
return Response.json({ error: 'Expected application/json' }, { status: 415 })
}
// Parse body — throws SyntaxError on invalid JSON
let body: unknown
try {
body = await request.json()
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
}
// Type narrowing after parse
if (
typeof body !== 'object' ||
body === null ||
!('name' in body) ||
typeof (body as Record<string, unknown>).name !== 'string'
) {
return Response.json({ error: 'Missing required field: name' }, { status: 422 })
}
const { name } = body as { name: string }
return Response.json({ created: true, name }, { status: 201 })
},
}JSON in Cloudflare KV
Cloudflare KV stores values as UTF-8 strings or ArrayBuffers. JSON must be serialized on write and parsed on read. KV provides a built-in { type: "json" } option that calls JSON.parse internally, returning null if the key does not exist.
// wrangler.toml:
// [[kv_namespaces]]
// binding = "CACHE"
// id = "abc123..."
interface Env {
CACHE: KVNamespace
}
// --- Write JSON to KV ---
async function saveUser(env: Env, userId: string, user: object) {
await env.CACHE.put(
`user:${userId}`,
JSON.stringify(user),
{ expirationTtl: 3600 } // seconds; optional
)
}
// --- Read JSON from KV (manual parse) ---
async function getUser(env: Env, userId: string) {
const raw = await env.CACHE.get(`user:${userId}`)
return raw ? JSON.parse(raw) : null
}
// --- Read JSON from KV (built-in type option) ---
async function getUserTyped(env: Env, userId: string) {
return env.CACHE.get(`user:${userId}`, { type: 'json' })
// Returns parsed value or null — no JSON.parse needed
}
// --- List keys with metadata ---
async function listProducts(env: Env) {
const list = await env.CACHE.list({ prefix: 'product:' })
const items = await Promise.all(
list.keys.map(k => env.CACHE.get(k.name, { type: 'json' }))
)
return items.filter(Boolean)
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const user = await getUserTyped(env, '42')
if (!user) return Response.json({ error: 'Not Found' }, { status: 404 })
return Response.json(user)
},
}KV is eventually consistent: writes propagate to all edge locations within approximately 60 seconds. Use KV for read-heavy data that can tolerate brief staleness — user profiles, feature flags, product catalogs. For strictly consistent data, use D1 or Durable Objects.
JSON with D1 (SQLite)
Cloudflare D1 is a serverless SQLite database. SQLite's JSON1 extension is available in D1, enabling json_extract(), json_set(), json_each(), and json_array_length() on TEXT columns that store JSON strings. This lets you query nested JSON fields without fetching and parsing the full row in application code.
// wrangler.toml:
// [[d1_databases]]
// binding = "DB"
// database_name = "mydb"
// database_id = "..."
interface Env {
DB: D1Database
}
// Schema: CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, attrs TEXT);
// attrs column stores JSON like: {"color":"red","size":"M","tags":["sale","new"]}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
// --- Insert a row with a JSON column ---
if (request.method === 'POST' && url.pathname === '/products') {
const body = await request.json() as { name: string; attrs: object }
const result = await env.DB
.prepare('INSERT INTO products (name, attrs) VALUES (?, ?) RETURNING id')
.bind(body.name, JSON.stringify(body.attrs))
.first<{ id: number }>()
return Response.json({ id: result?.id }, { status: 201 })
}
// --- Query with json_extract ---
if (url.pathname === '/products/red') {
const { results } = await env.DB
.prepare("SELECT id, name, json_extract(attrs, '$.color') AS color FROM products WHERE json_extract(attrs, '$.color') = ?")
.bind('red')
.all()
return Response.json(results)
}
// --- Update a nested JSON field with json_set ---
if (request.method === 'PATCH' && url.pathname.startsWith('/products/')) {
const id = url.pathname.split('/').pop()
const { color } = await request.json() as { color: string }
await env.DB
.prepare("UPDATE products SET attrs = json_set(attrs, '$.color', ?) WHERE id = ?")
.bind(color, id)
.run()
return Response.json({ updated: true })
}
// --- Expand a JSON array column with json_each ---
if (url.pathname === '/products/tags') {
const { results } = await env.DB
.prepare("SELECT p.id, p.name, t.value AS tag FROM products p, json_each(p.attrs, '$.tags') t")
.all()
return Response.json(results)
}
return Response.json({ error: 'Not Found' }, { status: 404 })
},
}| JSON1 function | Purpose | Example |
|---|---|---|
json_extract(col, path) | Read a field by JSONPath | json_extract(attrs, '$.color') |
json_set(col, path, val) | Update a field in place | json_set(attrs, '$.color', 'blue') |
json_each(col, path) | Expand an array to rows | json_each(attrs, '$.tags') |
json_array_length(col, path) | Count array elements | json_array_length(attrs, '$.tags') |
json_remove(col, path) | Delete a field | json_remove(attrs, '$.internal') |
Hono JSON API Framework
Hono is a lightweight (~12 KB) web framework built on Web Standards that runs natively on Cloudflare Workers. Its c.json(data, status?) method returns JSON responses with correct headers, and c.req.json() parses request bodies. Hono's router, middleware pipeline, and TypeScript generics significantly reduce boilerplate compared to a bare fetch handler.
// npm install hono
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
interface Env {
DB: D1Database
CACHE: KVNamespace
}
const app = new Hono<{ Bindings: Env }>()
// Global middleware
app.use('*', logger())
app.use('/api/*', cors({ origin: 'https://example.com' }))
// GET — return JSON
app.get('/api/products', async (c) => {
const { results } = await c.env.DB.prepare('SELECT * FROM products').all()
return c.json(results)
})
// GET by ID
app.get('/api/products/:id', async (c) => {
const id = c.req.param('id')
const product = await c.env.DB
.prepare('SELECT * FROM products WHERE id = ?')
.bind(id)
.first()
if (!product) return c.json({ error: 'Not Found' }, 404)
return c.json(product)
})
// POST — parse body with c.req.json()
app.post('/api/products', async (c) => {
const body = await c.req.json<{ name: string; price: number }>()
const result = await c.env.DB
.prepare('INSERT INTO products (name, price) VALUES (?, ?) RETURNING id')
.bind(body.name, body.price)
.first<{ id: number }>()
return c.json({ id: result?.id }, 201)
})
// PATCH — partial update
app.patch('/api/products/:id', async (c) => {
const id = c.req.param('id')
const body = await c.req.json<Partial<{ name: string; price: number }>>()
await c.env.DB
.prepare('UPDATE products SET name = COALESCE(?, name), price = COALESCE(?, price) WHERE id = ?')
.bind(body.name ?? null, body.price ?? null, id)
.run()
return c.json({ updated: true })
})
// Global error handler
app.onError((err, c) => {
console.error(err)
return c.json({ error: 'Internal Server Error' }, 500)
})
// 404 fallback
app.notFound((c) => c.json({ error: 'Not Found' }, 404))
export default appValidating JSON with Zod at the Edge
Zod runs in any V8 environment with no native bindings, making it suitable for Cloudflare Workers. Use schema.safeParse() to validate without throwing, then return structured error responses. With Hono, the @hono/zod-validator middleware handles validation before the route handler runs.
// npm install zod @hono/zod-validator
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const app = new Hono()
// --- Schema definitions ---
const CreateProductSchema = z.object({
name: z.string().min(1).max(100),
price: z.number().positive(),
category: z.enum(['electronics', 'clothing', 'food']),
tags: z.array(z.string()).max(10).optional(),
})
const UpdateProductSchema = CreateProductSchema.partial()
// --- Route with zValidator middleware ---
// Validation runs before handler; returns 400 on schema violation
app.post(
'/api/products',
zValidator('json', CreateProductSchema),
async (c) => {
// c.req.valid('json') is already typed and validated
const product = c.req.valid('json')
// product.name, product.price, product.category are type-safe
return c.json({ created: true, product }, 201)
}
)
// --- Manual validation without Hono ---
export default {
async fetch(request: Request): Promise<Response> {
if (request.method !== 'POST') {
return Response.json({ error: 'Method Not Allowed' }, { status: 405 })
}
let raw: unknown
try {
raw = await request.json()
} catch {
return Response.json({ error: 'Invalid JSON' }, { status: 400 })
}
const result = CreateProductSchema.safeParse(raw)
if (!result.success) {
return Response.json(
{ error: 'Validation failed', details: result.error.flatten().fieldErrors },
{ status: 422 }
)
}
const { name, price, category } = result.data
// ... store in DB
return Response.json({ name, price, category }, { status: 201 })
},
}Error Handling and JSON Error Responses
Consistent JSON error shapes make APIs easier for clients to handle programmatically. Return a structured error object — { error: string, code?: string, details?: unknown } — with every non-2xx response. Wrap your fetch handler in a top-level try/catch to prevent unhandled exceptions from returning HTML error pages.
// Shared error response helper
function jsonError(message: string, status: number, details?: unknown): Response {
return Response.json(
{ error: message, ...(details ? { details } : {}) },
{ status }
)
}
// Error code constants
const E = {
INVALID_JSON: 'INVALID_JSON',
VALIDATION: 'VALIDATION_ERROR',
NOT_FOUND: 'NOT_FOUND',
METHOD_MISMATCH: 'METHOD_NOT_ALLOWED',
SERVER_ERROR: 'INTERNAL_ERROR',
} as const
// Full fetch handler with error handling
export default {
async fetch(request: Request, env: Env): Promise<Response> {
try {
return await handleRequest(request, env)
} catch (err) {
// Log for Cloudflare Workers Logs / Logpush
console.error('Unhandled error:', err)
return Response.json(
{ error: 'Internal Server Error', code: E.SERVER_ERROR },
{ status: 500 }
)
}
},
}
async function handleRequest(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
if (url.pathname === '/api/items' && request.method === 'POST') {
// 1. Parse body
let body: unknown
try {
body = await request.json()
} catch {
return jsonError('Request body is not valid JSON', 400)
}
// 2. Validate shape
const parsed = ItemSchema.safeParse(body)
if (!parsed.success) {
return jsonError('Validation failed', 422, parsed.error.flatten().fieldErrors)
}
// 3. Business logic
const existing = await env.DB.prepare('SELECT id FROM items WHERE name = ?')
.bind(parsed.data.name).first()
if (existing) {
return jsonError(`Item "${parsed.data.name}" already exists`, 409)
}
const result = await env.DB
.prepare('INSERT INTO items (name) VALUES (?) RETURNING id')
.bind(parsed.data.name).first<{ id: number }>()
return Response.json({ id: result?.id }, { status: 201 })
}
return jsonError('Not Found', 404)
}| Scenario | HTTP status | Error shape |
|---|---|---|
| Malformed JSON body | 400 | { "error": "Invalid JSON" } |
| Schema validation failure | 422 | { "error": "...", "details": {...} } |
| Resource not found | 404 | { "error": "Not Found" } |
| Method not allowed | 405 | { "error": "Method Not Allowed" } |
| Unhandled exception | 500 | { "error": "Internal Server Error" } |
Definitions
- V8 isolate
- A sandboxed JavaScript execution context provided by the V8 engine. Each Cloudflare Worker request runs inside a V8 isolate, which has its own heap, global object, and JavaScript runtime. Isolates start in under 5 ms (compared to ~100 ms for a Node.js process) because they share the V8 engine process without copying it. All standard JavaScript globals — including
JSON.parse,JSON.stringify,Request,Response,fetch, andcrypto— are available inside every isolate. - KV store
- Cloudflare Workers KV is a globally distributed, eventually consistent key-value store. Keys are strings up to 512 bytes; values are strings or ArrayBuffers up to 25 MB. KV is optimized for high-read, low-write workloads — reads return in under 1 ms from the nearest edge location for cached data, while writes propagate to all regions within approximately 60 seconds. JSON data must be serialized with
JSON.stringifybefore writing and parsed withJSON.parseafter reading (or use the built-in{ type: "json" }option). - D1
- Cloudflare D1 is a serverless SQLite database that runs at the edge. It supports the full SQLite SQL dialect including the JSON1 extension for querying JSON stored in TEXT columns. D1 databases are co-located with Workers on Cloudflare's network, providing query latency of approximately 1–10 ms for read queries. D1 is strongly consistent within a region and supports read replication for global deployments. Each database can hold up to 10 GB of data and 10 billion rows.
- Hono
- Hono is an open-source, lightweight (~12 KB) web framework built entirely on Web Standard APIs —
Request,Response,URL, andHeaders. It runs without modification on Cloudflare Workers, Deno, Bun, Fastly Compute, and Node.js. Hono provides a router with path parameters, middleware composition, typed context objects, and helper methods includingc.json()for JSON responses andc.req.json()for body parsing. The@hono/zod-validatormiddleware integrates Zod schema validation into the middleware chain. - Edge runtime
- An edge runtime executes code at network edge locations geographically close to the user, rather than in a centralized data center. Cloudflare Workers run at 300+ edge locations worldwide, typically placing a Worker within 50 ms of any user. Edge runtimes implement a subset of the browser Web API rather than the full Node.js API — they include
fetch,Request,Response,crypto,TextEncoder, and Web Streams, but exclude Node.js-specific modules likefs,net, andhttp. JSON handling in edge runtimes is identical to browser and Node.js V8 behavior.
FAQ
How do I return a JSON response from a Cloudflare Worker?
Use Response.json(data) — the static constructor serializes data and sets Content-Type: application/json automatically. Pass an optional second argument to set the status code or headers: Response.json({ error: "Not Found" }, { status: 404 }). Avoid new Response(JSON.stringify(data)) — you must add the Content-Type header manually and it is easy to omit. Response.json() has been available in Cloudflare Workers since 2023 and is part of the WinterCG standard.
How do I parse a JSON request body in a Cloudflare Worker?
Call await request.json() inside an async fetch() handler. Wrap it in try/catch because it throws SyntaxError on malformed JSON — return a 400 response in the catch block. Check request.headers.get('Content-Type') before parsing to confirm the client sent JSON. A body stream can only be consumed once; call request.clone().json() for a second read (for example, in a logging middleware). With Hono, use await c.req.json() in any route handler.
How do I store and retrieve JSON in Cloudflare KV?
Write: await env.KV.put(key, JSON.stringify(value), { expirationTtl: 3600 }). Read with the built-in type option: await env.KV.get(key, { type: "json" }) — this calls JSON.parse internally and returns null if the key is missing. Alternatively, read the raw string and parse it: const raw = await env.KV.get(key); const value = raw ? JSON.parse(raw) : null. KV propagates writes to all edge regions in approximately 60 seconds, so read-after-write may return the previous value in other regions.
How do I use JSON with Cloudflare D1?
Store JSON in a TEXT column and use SQLite JSON1 functions to query it. Insert: .bind(JSON.stringify(attrs)). Query a field: SELECT json_extract(attrs, '$.color') FROM products WHERE json_extract(attrs, '$.color') = ?. Update a field: UPDATE products SET attrs = json_set(attrs, '$.color', ?) WHERE id = ?. Expand an array: SELECT p.id, t.value FROM products p, json_each(p.attrs, '$.tags') t. Fetch results with .all() and return results directly — D1 returns plain JavaScript objects.
What is Hono and why is it recommended for Cloudflare Workers JSON APIs?
Hono is a 12 KB web framework built on Web Standard APIs that runs natively on Cloudflare Workers. It provides a router with app.get(), app.post(), and other HTTP method helpers, middleware composition with app.use(), and typed context objects. c.json(data, status?) returns JSON responses with correct headers; c.req.json() parses request bodies; c.req.param('id') reads path parameters. The @hono/zod-validator middleware validates JSON bodies with Zod before the route handler runs. Hono's TypeScript generics give you end-to-end type safety for request bodies, path params, and environment bindings.
How do I validate JSON request bodies in a Cloudflare Worker with Zod?
Install zod — it has no native dependencies and runs in any V8 environment. Define a schema: const Schema = z.object({ name: z.string(), price: z.number().positive() }). After parsing the body, call Schema.safeParse(body) — it returns { success: true, data } or { success: false, error } without throwing. On failure: return Response.json({ error: "Validation failed", details: result.error.flatten().fieldErrors }, { status: 422 }). With Hono, use zValidator('json', Schema) as middleware — it validates automatically and calls the route handler only on success.
Does Cloudflare Workers support full JSON.parse and JSON.stringify?
Yes. Workers run on V8 isolates with the same JSON implementation as Node.js and Chrome. JSON.parse and JSON.stringify handle nested objects, arrays, strings, numbers, booleans, and null. They silently drop undefined, functions, and Symbols from objects (converting them to null inside arrays). BigInt throws unless you provide a replacer function. Performance matches Node.js: parsing a 1 MB JSON payload takes roughly 5–15 ms. There are no Worker-specific limitations on JSON size beyond the 128 MB isolate memory limit.
How should I handle JSON errors in a Cloudflare Worker?
Return structured JSON error objects with appropriate HTTP status codes for every non-2xx response. A consistent shape — { error: string, details?: unknown } — makes errors machine-readable. Wrap your entire handler in try/catch and return Response.json({ error: "Internal Server Error" }, { status: 500 }) on unhandled exceptions — otherwise Workers may return an HTML error page. Specific cases: malformed JSON body (400), schema validation failure (422), not found (404), wrong HTTP method (405). With Hono, use app.onError((err, c) => c.json({ error: err.message }, 500)) as a global error boundary.
Further reading and primary sources
- Cloudflare Workers Docs — Official Cloudflare Workers documentation
- Hono Framework — Fast edge-first web framework for Cloudflare Workers
- Cloudflare D1 Docs — Cloudflare D1 serverless SQL database documentation